Skip to main content

purple_ssh/handler/
confirm.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::mpsc;
4
5use crossterm::event::{KeyCode, KeyEvent};
6
7use crate::app::{App, Screen};
8use crate::event::AppEvent;
9
10/// Result of routing a confirm-dialog key event.
11///
12/// Confirm dialogs accept exactly three classes of keys:
13/// - `Yes`: y / Y
14/// - `No`: n / N / Esc
15/// - `Ignored`: anything else (must NOT change app state)
16///
17/// **Critical safety invariant**: a `_ =>` catch-all in a confirm handler
18/// that transitions screen state is forbidden. A misplaced keypress must not
19/// silently cancel a destructive operation. Use [`route_confirm_key`] in every
20/// confirm handler to enforce the contract.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ConfirmAction {
23    Yes,
24    No,
25    Ignored,
26}
27
28/// Single source of truth for confirm-dialog key routing.
29pub fn route_confirm_key(key: KeyEvent) -> ConfirmAction {
30    match key.code {
31        KeyCode::Char('y') | KeyCode::Char('Y') => ConfirmAction::Yes,
32        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => ConfirmAction::No,
33        _ => ConfirmAction::Ignored,
34    }
35}
36
37/// Run known_hosts import and set status. Used by both ConfirmImport and Welcome handlers.
38pub(super) fn execute_known_hosts_import(app: &mut App) {
39    let config_backup = app.hosts_state.ssh_config().clone();
40    match crate::import::import_from_known_hosts(
41        app.hosts_state.ssh_config_mut(),
42        Some("known_hosts"),
43    ) {
44        Ok((imported, skipped, _, _)) => {
45            if imported > 0 {
46                if let Err(e) = app.hosts_state.ssh_config().write() {
47                    app.hosts_state.set_ssh_config(config_backup);
48                    app.notify_error(crate::messages::failed_to_save(&e));
49                    return;
50                }
51                app.reload_hosts();
52                app.notify(crate::messages::imported_hosts(imported, skipped));
53            } else {
54                app.notify(crate::messages::all_hosts_exist(skipped));
55            }
56            app.ui.set_known_hosts_count(0);
57        }
58        Err(e) => {
59            app.notify_error(e);
60        }
61    }
62}
63
64pub(super) fn handle_import_key(app: &mut App, key: KeyEvent) {
65    match route_confirm_key(key) {
66        ConfirmAction::Yes => {
67            app.set_screen(Screen::HostList);
68            execute_known_hosts_import(app);
69        }
70        ConfirmAction::No => {
71            app.set_screen(Screen::HostList);
72        }
73        ConfirmAction::Ignored => {}
74    }
75}
76
77pub(super) fn handle_purge_stale_key(app: &mut App, key: KeyEvent) {
78    let Screen::ConfirmPurgeStale { provider: p, .. } = &app.screen else {
79        return;
80    };
81    let provider = p.clone();
82    let return_screen = if provider.is_some() {
83        Screen::Providers
84    } else {
85        Screen::HostList
86    };
87    match route_confirm_key(key) {
88        ConfirmAction::Yes => {
89            execute_purge_stale(app, provider.as_deref());
90            app.screen = return_screen;
91        }
92        ConfirmAction::No => {
93            app.screen = return_screen;
94        }
95        ConfirmAction::Ignored => {}
96    }
97}
98
99fn execute_purge_stale(app: &mut App, provider: Option<&str>) {
100    let stale = app.hosts_state.ssh_config().stale_hosts();
101    if stale.is_empty() {
102        return;
103    }
104    // Filter by provider if specified.
105    let targets: Vec<(String, u64)> = if let Some(prov) = provider {
106        stale
107            .into_iter()
108            .filter(|(alias, _)| {
109                app.hosts_state
110                    .ssh_config()
111                    .host_entries()
112                    .iter()
113                    .any(|e| e.alias == *alias && e.provider.as_deref() == Some(prov))
114            })
115            .collect()
116    } else {
117        stale
118    };
119    if targets.is_empty() {
120        return;
121    }
122    let config_backup = app.hosts_state.ssh_config().clone();
123    let count = targets.len();
124    for (alias, _) in &targets {
125        app.hosts_state.ssh_config_mut().delete_host(alias);
126    }
127    if let Err(e) = app.hosts_state.ssh_config().write() {
128        app.hosts_state.set_ssh_config(config_backup);
129        app.notify_error(crate::messages::failed_to_save(&e));
130        return;
131    }
132    // Kill active tunnels only after successful write (no rollback needed).
133    for (alias, _) in &targets {
134        if let Some(mut tunnel) = app.tunnels.active_remove(alias) {
135            let _ = tunnel.child.kill();
136            let _ = tunnel.child.wait();
137        }
138    }
139    app.hosts_state.clear_undo();
140    app.update_last_modified();
141    app.reload_hosts();
142    let msg = if let Some(prov) = provider {
143        let display = crate::providers::provider_display_name(prov);
144        format!(
145            "Removed {} stale {} host{}.",
146            count,
147            display,
148            if count == 1 { "" } else { "s" }
149        )
150    } else {
151        format!(
152            "Removed {} stale host{}.",
153            count,
154            if count == 1 { "" } else { "s" }
155        )
156    };
157    app.notify(msg);
158}
159
160pub(super) fn handle_delete_key(app: &mut App, key: KeyEvent) {
161    let Screen::ConfirmDelete { alias } = &app.screen else {
162        return;
163    };
164    let alias = alias.clone();
165    // Use the central confirm-key router so the y/n/Esc contract is uniform
166    // across all confirm dialogs.
167    match route_confirm_key(key) {
168        ConfirmAction::Yes => {
169            let siblings = app.hosts_state.ssh_config().siblings_of(&alias);
170
171            if !siblings.is_empty() {
172                // Multi-alias block: strip only the selected token.
173                // `delete_host_undoable` refuses this case (returning
174                // None) because re-inserting the whole element via
175                // `insert_host_at` cannot reverse a token strip. We
176                // therefore skip the undo stack and surface the event
177                // via a dedicated toast that names the surviving
178                // siblings, so the user knows what did and did not
179                // change on disk.
180                app.hosts_state.ssh_config_mut().delete_host(&alias);
181                if let Err(e) = app.hosts_state.ssh_config().write() {
182                    // Disk write failed: reload from disk to discard
183                    // the in-memory strip so view and storage match.
184                    app.notify_error(crate::messages::failed_to_save(&e));
185                    app.reload_hosts();
186                } else {
187                    if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
188                        let _ = tunnel.child.kill();
189                        let _ = tunnel.child.wait();
190                    }
191                    app.update_last_modified();
192                    app.reload_hosts();
193                    app.notify(crate::messages::siblings_stripped(&alias, siblings.len()));
194                }
195            } else if let Some((element, position)) = app
196                .hosts_state
197                .ssh_config_mut()
198                .delete_host_undoable(&alias)
199            {
200                if let Err(e) = app.hosts_state.ssh_config().write() {
201                    // Restore the element on write failure
202                    app.hosts_state
203                        .ssh_config_mut()
204                        .insert_host_at(element, position);
205                    app.notify_error(crate::messages::failed_to_save(&e));
206                } else {
207                    // Stop active tunnel for the deleted host
208                    if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
209                        let _ = tunnel.child.kill();
210                        let _ = tunnel.child.wait();
211                    }
212                    // Clean up cert file if it exists. NotFound is the
213                    // expected case for hosts that never had a cert. Other
214                    // errors are surfaced via the status bar (never via
215                    // eprintln, which would corrupt the ratatui screen).
216                    let mut cert_cleanup_warning: Option<String> = None;
217                    if !crate::demo_flag::is_demo() {
218                        if let Ok(cert_path) = crate::vault_ssh::cert_path_for(&alias) {
219                            match std::fs::remove_file(&cert_path) {
220                                Ok(()) => {}
221                                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
222                                Err(e) => {
223                                    cert_cleanup_warning =
224                                        Some(crate::messages::cert_cleanup_warning(
225                                            &cert_path.display(),
226                                            &e,
227                                        ));
228                                }
229                            }
230                        }
231                    }
232                    app.hosts_state
233                        .undo_stack_mut()
234                        .push(crate::app::DeletedHost { element, position });
235                    if app.hosts_state.undo_stack().len() > 50 {
236                        app.hosts_state.undo_stack_mut().remove(0);
237                    }
238                    app.update_last_modified();
239                    app.reload_hosts();
240                    if let Some(warning) = cert_cleanup_warning {
241                        app.notify_error(warning);
242                    } else {
243                        app.notify(crate::messages::goodbye_host(&alias));
244                    }
245                }
246            } else {
247                app.notify_warning(crate::messages::host_not_found(&alias));
248            }
249            app.set_screen(Screen::HostList);
250        }
251        ConfirmAction::No => {
252            app.set_screen(Screen::HostList);
253        }
254        ConfirmAction::Ignored => {}
255    }
256}
257
258pub(super) fn handle_vault_sign_key(
259    app: &mut App,
260    key: KeyEvent,
261    events_tx: &mpsc::Sender<AppEvent>,
262) {
263    // Vault Sign is a destructive/material action: signing N certificates
264    // hits Vault, may take time and is hard to reverse. Stray keys must NOT
265    // cancel. use `route_confirm_key` so only y/Y/n/N/Esc are honored.
266    // History: an earlier `_ => app.screen = Screen::HostList` catch-all
267    // could be triggered by any keypress next to `y` (e.g. fat-fingered
268    // `t` or `u`), silently aborting a bulk sign.
269    match route_confirm_key(key) {
270        ConfirmAction::Yes => {
271            // Extract the precomputed signable list, then transition back to
272            // the host list and kick off the background signing loop.
273            let signable = if let Screen::ConfirmVaultSign { signable } = &app.screen {
274                signable.clone()
275            } else {
276                return;
277            };
278            app.set_screen(Screen::HostList);
279            start_vault_bulk_sign(app, signable, events_tx);
280        }
281        ConfirmAction::No => {
282            app.set_screen(Screen::HostList);
283        }
284        ConfirmAction::Ignored => {}
285    }
286}
287
288/// Start the background vault bulk sign loop with fast-fail, progress, TOCTOU
289/// coordination and cancellation. Stores the JoinHandle on App for clean exit.
290fn start_vault_bulk_sign(
291    app: &mut App,
292    signable: Vec<crate::vault_ssh::VaultSignTarget>,
293    events_tx: &mpsc::Sender<AppEvent>,
294) {
295    let total = signable.len();
296    if total == 0 {
297        return;
298    }
299    app.notify_progress(crate::messages::vault_signing_progress(
300        crate::animation::SPINNER_FRAMES[0],
301        0,
302        total,
303        "",
304    ));
305
306    let cancel = Arc::new(AtomicBool::new(false));
307    app.vault.set_signing_cancel(cancel.clone());
308
309    let in_flight = app.vault.sign_in_flight().clone();
310    let tx = events_tx.clone();
311    let spawn_result = std::thread::Builder::new()
312        .name("vault-bulk-sign".into())
313        .spawn(move || {
314            let mut signed = 0u32;
315            let mut failed = 0u32;
316            let mut skipped = 0u32;
317            let mut consecutive_failures = 0usize;
318            let mut first_error: Option<String> = None;
319            let mut aborted_message: Option<String> = None;
320
321            for (idx, target) in signable.iter().enumerate() {
322                let crate::vault_ssh::VaultSignTarget {
323                    alias,
324                    role,
325                    certificate_file: cert_file,
326                    pubkey,
327                    vault_addr,
328                } = target;
329                if cancel.load(Ordering::Relaxed) {
330                    break;
331                }
332                let done = idx + 1;
333
334                // TOCTOU: skip host if another thread already has it in-flight.
335                // Otherwise mark it in-flight for the duration of this iteration.
336                {
337                    // If the mutex is poisoned a worker thread panicked while holding
338                    // the lock. Recover the inner value without clearing. clearing
339                    // the whole set would make every in-flight alias simultaneously
340                    // eligible for re-signing, risking duplicate cert writes.
341                    let mut set = match in_flight.lock() {
342                        Ok(g) => g,
343                        Err(p) => p.into_inner(),
344                    };
345                    if !set.insert(alias.clone()) {
346                        skipped += 1;
347                        let _ = tx.send(AppEvent::VaultSignProgress {
348                            alias: alias.clone(),
349                            done,
350                            total,
351                        });
352                        continue;
353                    }
354                }
355
356                let _ = tx.send(AppEvent::VaultSignProgress {
357                    alias: alias.clone(),
358                    done,
359                    total,
360                });
361
362                let cert_path = match crate::vault_ssh::resolve_cert_path(alias, cert_file) {
363                    Ok(p) => p,
364                    Err(e) => {
365                        failed += 1;
366                        consecutive_failures += 1;
367                        let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
368                        if first_error.is_none() {
369                            first_error = Some(scrubbed);
370                        }
371                        remove_in_flight(&in_flight, alias);
372                        if consecutive_failures >= 3 {
373                            aborted_message = Some(crate::messages::vault_signing_aborted(
374                                failed,
375                                first_error.as_deref(),
376                            ));
377                            break;
378                        }
379                        continue;
380                    }
381                };
382                let status = crate::vault_ssh::check_cert_validity(&cert_path);
383                if !crate::vault_ssh::needs_renewal(&status) {
384                    skipped += 1;
385                    consecutive_failures = 0;
386                    remove_in_flight(&in_flight, alias);
387                    continue;
388                }
389
390                let sign_result =
391                    crate::vault_ssh::sign_certificate(role, pubkey, alias, vault_addr.as_deref());
392                // Always clean up in_flight for this alias before handling the
393                // result. Using a single cleanup point (rather than per-arm)
394                // prevents orphaned aliases when new control flow is added.
395                remove_in_flight(&in_flight, alias);
396                match sign_result {
397                    Ok(_) => {
398                        let _ = tx.send(AppEvent::VaultSignResult {
399                            alias: alias.clone(),
400                            certificate_file: cert_file.clone(),
401                            success: true,
402                            message: String::new(),
403                        });
404                        signed += 1;
405                        consecutive_failures = 0;
406                    }
407                    Err(e) => {
408                        let raw = e.to_string();
409                        let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
410                        if first_error.is_none() {
411                            first_error = Some(scrubbed.clone());
412                        }
413                        let _ = tx.send(AppEvent::VaultSignResult {
414                            alias: alias.clone(),
415                            certificate_file: cert_file.clone(),
416                            success: false,
417                            message: scrubbed,
418                        });
419                        failed += 1;
420                        consecutive_failures += 1;
421                        if consecutive_failures >= 3 {
422                            aborted_message = Some(crate::messages::vault_signing_aborted(
423                                failed,
424                                first_error.as_deref(),
425                            ));
426                            break;
427                        }
428                    }
429                }
430            }
431
432            let cancelled = cancel.load(Ordering::Relaxed);
433            let _ = tx.send(AppEvent::VaultSignAllDone {
434                signed,
435                failed,
436                skipped,
437                cancelled,
438                aborted_message,
439                first_error,
440            });
441        });
442    match spawn_result {
443        Ok(handle) => {
444            log::info!("[purple] vault sign thread: spawned");
445            app.vault.set_sign_thread(handle);
446        }
447        Err(e) => {
448            // Spawn failed (e.g. OS thread limit). Clear the cancel flag and
449            // surface the error. otherwise the status bar is stuck at
450            // "Signing 0/N" with no way for the user to recover.
451            log::warn!("[purple] vault sign thread: spawn failed: {}", e);
452            let _ = app.vault.finalize_signing_run();
453            app.notify_error(crate::messages::vault_spawn_failed(&e));
454        }
455    }
456}
457
458pub(super) fn remove_in_flight(
459    set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
460    alias: &str,
461) {
462    // On mutex poison, recover the inner value and remove only the target alias.
463    // Do NOT clear the entire set. other in-flight aliases are still owned by
464    // live worker iterations and clearing them would allow duplicate signs.
465    let mut guard = match set.lock() {
466        Ok(g) => g,
467        Err(p) => p.into_inner(),
468    };
469    guard.remove(alias);
470}
471
472pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
473    let Screen::ConfirmHostKeyReset {
474        alias,
475        hostname,
476        known_hosts_path,
477        askpass,
478    } = &app.screen
479    else {
480        return;
481    };
482    let alias = alias.clone();
483    let hostname = hostname.clone();
484    let known_hosts_path = known_hosts_path.clone();
485    let askpass = askpass.clone();
486    // Host key reset wipes the host's known_hosts entry. uniform y/n/Esc
487    // contract via the central router so stray keys cannot trigger it.
488    match route_confirm_key(key) {
489        ConfirmAction::Yes => {
490            let output = std::process::Command::new("ssh-keygen")
491                .arg("-R")
492                .arg(&hostname)
493                .arg("-f")
494                .arg(&known_hosts_path)
495                .output();
496
497            match output {
498                Ok(result) if result.status.success() => {
499                    app.notify(crate::messages::removed_host_key(&hostname));
500                    if app.demo_mode {
501                        app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
502                    } else {
503                        app.ui.queue_connect(alias, askpass);
504                    }
505                }
506                Ok(result) => {
507                    let stderr = String::from_utf8_lossy(&result.stderr);
508                    app.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
509                }
510                Err(e) => {
511                    app.notify_error(crate::messages::ssh_keygen_failed(&e));
512                }
513            }
514            app.set_screen(Screen::HostList);
515        }
516        ConfirmAction::No => {
517            app.set_screen(Screen::HostList);
518        }
519        ConfirmAction::Ignored => {}
520    }
521}
522
523/// Confirm handler for `K` (kick = restart). On Yes, queues a
524/// `ContainerActionKind::Restart` request; the main loop picks it
525/// up, fires the SSH command, and emits a result event. On No or
526/// Esc, the screen drops without side effects.
527pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
528    let Screen::ConfirmContainerRestart {
529        alias,
530        container_id,
531        container_name,
532        ..
533    } = &app.screen
534    else {
535        return;
536    };
537    let alias = alias.clone();
538    let container_id = container_id.clone();
539    let container_name = container_name.clone();
540    match route_confirm_key(key) {
541        ConfirmAction::Yes => {
542            queue_container_action(
543                app,
544                alias,
545                container_id,
546                container_name,
547                crate::containers::ContainerAction::Restart,
548            );
549            app.set_screen(Screen::HostList);
550        }
551        ConfirmAction::No => {
552            app.set_screen(Screen::HostList);
553        }
554        ConfirmAction::Ignored => {}
555    }
556}
557
558/// Confirm handler for `S` (stop). Same shape as restart; the action
559/// kind differs and so does the destructive wording in the dialog
560/// body.
561pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
562    let Screen::ConfirmContainerStop {
563        alias,
564        container_id,
565        container_name,
566        ..
567    } = &app.screen
568    else {
569        return;
570    };
571    let alias = alias.clone();
572    let container_id = container_id.clone();
573    let container_name = container_name.clone();
574    match route_confirm_key(key) {
575        ConfirmAction::Yes => {
576            queue_container_action(
577                app,
578                alias,
579                container_id,
580                container_name,
581                crate::containers::ContainerAction::Stop,
582            );
583            app.set_screen(Screen::HostList);
584        }
585        ConfirmAction::No => {
586            app.set_screen(Screen::HostList);
587        }
588        ConfirmAction::Ignored => {}
589    }
590}
591
592/// Confirm handler for `Ctrl-K` (stack kick). Iterates the stored
593/// member list and queues a Restart for each through the same drain
594/// mechanism that powers single-container restart. The drain
595/// processes one request per tick, giving a sequential cadence.
596pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
597    let Screen::ConfirmStackRestart { alias, members, .. } = &app.screen else {
598        return;
599    };
600    let alias = alias.clone();
601    let members = members.clone();
602    match route_confirm_key(key) {
603        ConfirmAction::Yes => {
604            for m in members {
605                queue_container_action(
606                    app,
607                    alias.clone(),
608                    m.container_id,
609                    m.container_name,
610                    crate::containers::ContainerAction::Restart,
611                );
612            }
613            app.set_screen(Screen::HostList);
614        }
615        ConfirmAction::No => {
616            app.set_screen(Screen::HostList);
617        }
618        ConfirmAction::Ignored => {}
619    }
620}
621
622/// Confirm handler for `K` on a host-divider row in the containers
623/// overview. Iterates every running container of the host and queues
624/// a Restart, regardless of compose project. Mirrors the stack-restart
625/// drain. one request per tick keeps remote SSH sane.
626pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
627    let Screen::ConfirmHostRestartAll { alias, members } = &app.screen else {
628        return;
629    };
630    let alias = alias.clone();
631    let members = members.clone();
632    match route_confirm_key(key) {
633        ConfirmAction::Yes => {
634            for m in members {
635                queue_container_action(
636                    app,
637                    alias.clone(),
638                    m.container_id,
639                    m.container_name,
640                    crate::containers::ContainerAction::Restart,
641                );
642            }
643            app.set_screen(Screen::HostList);
644        }
645        ConfirmAction::No => {
646            app.set_screen(Screen::HostList);
647        }
648        ConfirmAction::Ignored => {}
649    }
650}
651
652/// Confirm handler for `S` on a host-divider row. Stops every running
653/// container on the host. Same drain shape as host-restart.
654pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
655    let Screen::ConfirmHostStopAll { alias, members } = &app.screen else {
656        return;
657    };
658    let alias = alias.clone();
659    let members = members.clone();
660    match route_confirm_key(key) {
661        ConfirmAction::Yes => {
662            for m in members {
663                queue_container_action(
664                    app,
665                    alias.clone(),
666                    m.container_id,
667                    m.container_name,
668                    crate::containers::ContainerAction::Stop,
669                );
670            }
671            app.set_screen(Screen::HostList);
672        }
673        ConfirmAction::No => {
674            app.set_screen(Screen::HostList);
675        }
676        ConfirmAction::Ignored => {}
677    }
678}
679
680fn queue_container_action(
681    app: &mut App,
682    alias: String,
683    container_id: String,
684    container_name: String,
685    action: crate::containers::ContainerAction,
686) {
687    let Some(entry) = app.container_state.cache_entry(&alias) else {
688        log::debug!(
689            "[purple] container_action: queue aborted, no cache for alias={}",
690            alias
691        );
692        return;
693    };
694    let runtime = entry.runtime;
695    let askpass = app
696        .hosts_state
697        .list()
698        .iter()
699        .find(|h| h.alias == alias)
700        .and_then(|h| h.askpass.clone());
701    log::info!(
702        "[purple] container_action queued: alias={} id={} action={:?}",
703        alias,
704        container_id,
705        action
706    );
707    app.container_state
708        .queue_action(crate::app::ContainerActionRequest {
709            alias,
710            askpass,
711            runtime,
712            container_id,
713            container_name,
714            action,
715        });
716}
717
718/// Confirm for the `p` push action from the Keys tab. Stakes test:
719/// pushing modifies remote `authorized_keys`, so the footer uses
720/// action verbs (`push` / `keep`) and we only accept y/n/Esc.
721pub(super) fn handle_key_push_key(
722    app: &mut App,
723    key: KeyEvent,
724    events_tx: &mpsc::Sender<AppEvent>,
725) {
726    match route_confirm_key(key) {
727        ConfirmAction::Yes => {
728            let key_index = match &app.screen {
729                Screen::ConfirmKeyPush { key_index } => *key_index,
730                _ => return,
731            };
732            let aliases = std::mem::take(&mut app.keys.push_mut().committed);
733            app.set_screen(Screen::HostList);
734            start_key_push(app, key_index, aliases, events_tx);
735        }
736        ConfirmAction::No => {
737            // Return to the picker with the selection still intact so the
738            // user can refine it.
739            let key_index = match &app.screen {
740                Screen::ConfirmKeyPush { key_index } => *key_index,
741                _ => return,
742            };
743            app.keys.push_mut().committed.clear();
744            app.set_screen(Screen::KeyPushPicker { key_index });
745        }
746        ConfirmAction::Ignored => {}
747    }
748}
749
750/// Spawn the background push worker. Reads the pubkey from disk on the
751/// main thread (cheap) so we surface an early error toast before
752/// committing to the run. On read failure we abort and stay on
753/// HostList. Refuses to start a second push while a first is still in
754/// flight (`expected_count > 0`); the user must press Esc to cancel
755/// before triggering another run.
756fn start_key_push(
757    app: &mut App,
758    key_index: usize,
759    aliases: Vec<String>,
760    events_tx: &mpsc::Sender<AppEvent>,
761) {
762    // Refuse second push while a previous run still has live state OR a
763    // worker handle that has not been observed to finish. Belt-and-braces:
764    // expected_count protects the in-flight branch, worker.is_finished()
765    // protects the post-cancel branch where the worker is still draining
766    // but its results no longer count toward any expected total.
767    if app.keys.push().expected_count > 0
768        || app
769            .keys
770            .push()
771            .worker
772            .as_ref()
773            .is_some_and(|h| !h.is_finished())
774    {
775        log::debug!(
776            "[purple] key_push: rejected second push, run already in progress ({} of {})",
777            app.keys.push().results.len(),
778            app.keys.push().expected_count
779        );
780        app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
781        return;
782    }
783    if aliases.is_empty() {
784        log::debug!("[purple] key_push: rejected, no aliases committed");
785        app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
786        return;
787    }
788    let Some(key_info) = app.keys.list().get(key_index).cloned() else {
789        return;
790    };
791    if key_info.is_certificate {
792        app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
793        return;
794    }
795    let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
796    let raw = match crate::key_push::read_pubkey_file(&pub_path) {
797        Ok(s) => s,
798        Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
799            log::warn!(
800                "[purple] key_push: pubkey too large path={} bytes={}",
801                pub_path.display(),
802                n
803            );
804            app.notify_error(crate::messages::key_push_pubkey_too_large(
805                &key_info.name,
806                n,
807            ));
808            return;
809        }
810        Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
811            log::warn!(
812                "[purple] key_push: pubkey not a regular file path={}",
813                pub_path.display()
814            );
815            app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
816            return;
817        }
818        Err(_) => {
819            // Other validation variants are unreachable here (read_pubkey_file
820            // only returns TooLarge / NotARegularFile / IO collapsed into
821            // NotARegularFile). Defensive fallthrough.
822            app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
823            return;
824        }
825    };
826    let pubkey = match crate::key_push::validate_pubkey(&raw) {
827        Ok(s) => s,
828        Err(err) => {
829            let detail = match &err {
830                crate::key_push::PubkeyValidationError::Empty => "file is empty",
831                crate::key_push::PubkeyValidationError::MultiLine => {
832                    "must be a single line; multi-line input is rejected"
833                }
834                crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
835                    "key algorithm not allowed for static push"
836                }
837                crate::key_push::PubkeyValidationError::MalformedBase64 => {
838                    "base64 key body did not parse"
839                }
840                _ => "unexpected format",
841            };
842            log::warn!(
843                "[purple] key_push: invalid pubkey path={} err={:?}",
844                pub_path.display(),
845                err
846            );
847            app.notify_error(crate::messages::key_push_invalid_pubkey(
848                &key_info.name,
849                detail,
850            ));
851            return;
852        }
853    };
854
855    // Reset accumulators and start a new run.
856    let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
857
858    app.notify_progress(crate::messages::key_push_in_progress(
859        &key_info.name,
860        aliases.len(),
861    ));
862
863    let config_path = app.hosts_state.ssh_config().path.clone();
864    let tx = events_tx.clone();
865    let pubkey_payload = pubkey;
866    let handle = std::thread::Builder::new()
867        .name("key-push".into())
868        .spawn(move || {
869            for alias in aliases {
870                if cancel.load(Ordering::Relaxed) {
871                    break;
872                }
873                let outcome =
874                    crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
875                let _ = tx.send(AppEvent::KeyPushResult {
876                    run_id,
877                    result: crate::key_push::KeyPushResult { alias, outcome },
878                });
879            }
880        });
881    match handle {
882        Ok(h) => {
883            app.keys.push_mut().worker = Some(h);
884        }
885        Err(e) => {
886            log::error!("[purple] key_push: failed to spawn worker: {}", e);
887            // Drop the progress toast through the status-center invariant
888            // so the user does not see "Pushing..." stuck under the
889            // failure message.
890            app.status_center.clear_sticky_status();
891            app.notify_error(crate::messages::key_push_thread_spawn_failed());
892            app.keys.push_mut().clear_inflight_state();
893        }
894    }
895}
896
897#[cfg(test)]
898mod key_push_confirm_tests {
899    //! Coverage for the gate functions wrapping the push-worker spawn.
900    //! Every test exercises a guard path (already-running, missing pubkey,
901    //! certificate key, empty selection, return-to-picker) and asserts the
902    //! observable state. The happy-spawn path is intentionally not unit
903    //! tested here because it forks an ssh subprocess; that path is
904    //! covered by the event-loop tests against the run-completion flow.
905    use super::*;
906    use crate::ssh_config::model::SshConfigFile;
907    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
908
909    fn make_app() -> (App, std::path::PathBuf) {
910        let scratch = tempfile::tempdir().expect("tempdir").keep();
911        crate::preferences::set_path_override(scratch.join("preferences"));
912        crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
913        let config = SshConfigFile {
914            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
915            path: scratch.join("test_config"),
916            crlf: false,
917            bom: false,
918        };
919        let mut app = App::new(config);
920        // Seed a non-cert key whose .pub file lives in the scratch dir so
921        // `read_pubkey_file` succeeds via the override path.
922        let pub_path = scratch.join("id_test.pub");
923        std::fs::write(
924            &pub_path,
925            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
926        )
927        .unwrap();
928        app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
929            name: "id_test".into(),
930            display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
931            key_type: "ED25519".into(),
932            bits: "256".into(),
933            fingerprint: String::new(),
934            comment: "test@host".into(),
935            linked_hosts: vec![],
936            bishop_art: String::new(),
937            strength_score: 95,
938            encrypted: false,
939            agent_loaded: false,
940            is_certificate: false,
941            mtime_ts: None,
942        });
943        (app, scratch)
944    }
945
946    fn k(code: KeyCode) -> KeyEvent {
947        KeyEvent::new(code, KeyModifiers::NONE)
948    }
949
950    #[test]
951    fn n_returns_to_picker_with_key_index_preserved() {
952        let (mut app, _scratch) = make_app();
953        app.keys.push_mut().committed = vec!["h1".into()];
954        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
955        let (tx, _rx) = mpsc::channel();
956        handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
957        match app.screen {
958            Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
959            ref other => panic!("expected KeyPushPicker, got {:?}", other),
960        }
961        assert!(
962            app.keys.push().committed.is_empty(),
963            "n should drop the frozen selection"
964        );
965    }
966
967    #[test]
968    fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
969        let (mut app, _scratch) = make_app();
970        app.keys.push_mut().committed = vec!["h1".into()];
971        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
972        let (tx, _rx) = mpsc::channel();
973        handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
974        assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
975    }
976
977    #[test]
978    fn start_rejects_when_a_previous_run_is_still_in_flight() {
979        let (mut app, _scratch) = make_app();
980        app.keys.push_mut().expected_count = 2;
981        app.keys
982            .push_mut()
983            .results
984            .push(crate::key_push::KeyPushResult {
985                alias: "h1".into(),
986                outcome: crate::key_push::KeyPushOutcome::Appended,
987            });
988        let (tx, _rx) = mpsc::channel();
989        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
990        assert_eq!(
991            app.keys.push().expected_count,
992            2,
993            "guard must not reset in-flight state"
994        );
995        let toast = app.status_center.toast().expect("toast set");
996        assert!(
997            toast.text.contains("already running"),
998            "expected 'already running' warning, got: {}",
999            toast.text
1000        );
1001    }
1002
1003    #[test]
1004    fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1005        let (mut app, _scratch) = make_app();
1006        let (tx, _rx) = mpsc::channel();
1007        start_key_push(&mut app, 0, Vec::new(), &tx);
1008        assert_eq!(app.keys.push().expected_count, 0);
1009        assert!(app.keys.push().worker.is_none());
1010        let toast = app.status_center.toast().expect("toast set");
1011        assert!(toast.is_error());
1012    }
1013
1014    #[test]
1015    fn start_rejects_certificate_key() {
1016        let (mut app, _scratch) = make_app();
1017        app.keys.list_mut()[0].is_certificate = true;
1018        let (tx, _rx) = mpsc::channel();
1019        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1020        assert_eq!(app.keys.push().expected_count, 0);
1021        assert!(app.keys.push().worker.is_none());
1022        let toast = app.status_center.toast().expect("toast set");
1023        assert!(toast.is_error());
1024        assert!(toast.text.contains("Certificates"));
1025    }
1026
1027    #[test]
1028    fn start_rejects_missing_pubkey_file() {
1029        let (mut app, _scratch) = make_app();
1030        app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1031        let (tx, _rx) = mpsc::channel();
1032        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1033        assert_eq!(app.keys.push().expected_count, 0);
1034        let toast = app.status_center.toast().expect("toast set");
1035        assert!(toast.is_error());
1036    }
1037
1038    #[test]
1039    fn start_rejects_invalid_pubkey_content() {
1040        let (mut app, scratch) = make_app();
1041        // Multi-line pubkey: the canonical command-injection PoC. Must be
1042        // rejected without spawning the worker.
1043        let pub_path = scratch.join("id_bad.pub");
1044        std::fs::write(
1045            &pub_path,
1046            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1047        )
1048        .unwrap();
1049        app.keys.list_mut()[0].display_path =
1050            pub_path.with_extension("").to_string_lossy().into_owned();
1051        app.keys.list_mut()[0].name = "id_bad".into();
1052        let (tx, _rx) = mpsc::channel();
1053        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1054        assert_eq!(app.keys.push().expected_count, 0);
1055        assert!(app.keys.push().worker.is_none());
1056        let toast = app.status_center.toast().expect("toast set");
1057        assert!(toast.is_error());
1058        assert!(toast.text.contains("validation"));
1059    }
1060}