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 super::ctx::{Effectful, Effects, Nav, Notify};
8use crate::app::{App, ContainerState, HostState, KeysState, Screen, StatusCenter, UiSelection};
9use crate::event::AppEvent;
10
11/// The slice of App a single-domain confirm dialog touches: the screen (read to
12/// extract the confirm payload, written to transition out) and the deferred
13/// effect queue. The confirmed Yes-action of these dialogs is a terminal
14/// whole-App operation (run the known-hosts import, purge stale hosts, kick off
15/// the vault bulk-sign), so it runs as a deferred effect after the slice borrow
16/// ends. The slice never reaches into hosts, vault, containers or any other
17/// domain directly.
18struct ConfirmCtx<'a> {
19    screen: &'a mut Screen,
20    effects: Effects,
21}
22
23impl Nav for ConfirmCtx<'_> {
24    fn screen_mut(&mut self) -> &mut Screen {
25        self.screen
26    }
27}
28
29impl Effectful for ConfirmCtx<'_> {
30    fn effects_mut(&mut self) -> &mut Effects {
31        &mut self.effects
32    }
33}
34
35/// The slice of App the key-push confirm touches: the Keys-tab push state
36/// (the frozen alias selection) and the screen. Confirming kicks off the
37/// background push worker, a terminal whole-App op (reads the key list and
38/// config path, spawns a thread), so it runs deferred after the screen
39/// transition. Declining clears the frozen selection on the slice and returns
40/// to the picker. The slice never reaches into hosts, vault or any other
41/// domain directly.
42struct KeyPushConfirmCtx<'a> {
43    keys: &'a mut KeysState,
44    screen: &'a mut Screen,
45    effects: Effects,
46}
47
48impl Nav for KeyPushConfirmCtx<'_> {
49    fn screen_mut(&mut self) -> &mut Screen {
50        self.screen
51    }
52}
53
54impl Effectful for KeyPushConfirmCtx<'_> {
55    fn effects_mut(&mut self) -> &mut Effects {
56        &mut self.effects
57    }
58}
59
60/// The slice of App the host-key-reset confirm touches: the connect queue and
61/// known-hosts count on `ui`, the status center, the screen and the demo flag.
62/// The `ssh-keygen -R` subprocess runs inline (it owns its arguments and reads
63/// no App state) and its result drives only slice-local notifications and the
64/// queued connect, so nothing is deferred. The slice never reaches into hosts,
65/// vault or any other domain directly.
66struct HostKeyResetCtx<'a> {
67    ui: &'a mut UiSelection,
68    status: &'a mut StatusCenter,
69    screen: &'a mut Screen,
70    demo_mode: bool,
71}
72
73impl Nav for HostKeyResetCtx<'_> {
74    fn screen_mut(&mut self) -> &mut Screen {
75        self.screen
76    }
77}
78
79impl Notify for HostKeyResetCtx<'_> {
80    fn status_mut(&mut self) -> &mut StatusCenter {
81        self.status
82    }
83}
84
85/// The slice of App every container-action confirm touches: the container cache
86/// and pending-action queue (`container_state`), the host list (read-only, to
87/// resolve each target's askpass), and the screen. Confirming queues one
88/// `ContainerActionRequest` per target. the drain loop later spawns the SSH
89/// thread, so the confirm itself runs entirely on the slice with no deferred
90/// whole-App effect. The slice never reaches into vault, providers or any other
91/// domain directly.
92struct ContainerConfirmCtx<'a> {
93    container_state: &'a mut ContainerState,
94    hosts: &'a HostState,
95    screen: &'a mut Screen,
96}
97
98impl Nav for ContainerConfirmCtx<'_> {
99    fn screen_mut(&mut self) -> &mut Screen {
100        self.screen
101    }
102}
103
104/// Result of routing a confirm-dialog key event.
105///
106/// Confirm dialogs accept exactly three classes of keys:
107/// - `Yes`: y / Y
108/// - `No`: n / N / Esc
109/// - `Ignored`: anything else (must NOT change app state)
110///
111/// **Critical safety invariant**: a `_ =>` catch-all in a confirm handler
112/// that transitions screen state is forbidden. A misplaced keypress must not
113/// silently cancel a destructive operation. Use [`route_confirm_key`] in every
114/// confirm handler to enforce the contract.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ConfirmAction {
117    Yes,
118    No,
119    Ignored,
120}
121
122/// Single source of truth for confirm-dialog key routing.
123pub fn route_confirm_key(key: KeyEvent) -> ConfirmAction {
124    match key.code {
125        KeyCode::Char('y') | KeyCode::Char('Y') => ConfirmAction::Yes,
126        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => ConfirmAction::No,
127        _ => ConfirmAction::Ignored,
128    }
129}
130
131/// Run known_hosts import and set status. Used by both ConfirmImport and Welcome handlers.
132pub(super) fn execute_known_hosts_import(app: &mut App) {
133    let config_backup = app.hosts_state.ssh_config().clone();
134    match crate::import::import_from_known_hosts(
135        app.env.paths(),
136        app.hosts_state.ssh_config_mut(),
137        Some("known_hosts"),
138    ) {
139        Ok((imported, skipped, _, _)) => {
140            if imported > 0 {
141                if let Err(e) = app.hosts_state.ssh_config().write() {
142                    app.hosts_state.set_ssh_config(config_backup);
143                    app.notify_error(crate::messages::failed_to_save(&e));
144                    return;
145                }
146                app.reload_hosts();
147                app.notify(crate::messages::imported_hosts(imported, skipped));
148            } else {
149                app.notify(crate::messages::all_hosts_exist(skipped));
150            }
151            app.ui.set_known_hosts_count(0);
152        }
153        Err(e) => {
154            app.notify_error(e);
155        }
156    }
157}
158
159pub(super) fn handle_import_key(app: &mut App, key: KeyEvent) {
160    let effects = {
161        let mut ctx = ConfirmCtx {
162            screen: &mut app.screen,
163            effects: Effects::default(),
164        };
165        match route_confirm_key(key) {
166            ConfirmAction::Yes => {
167                ctx.set_screen(Screen::HostList);
168                ctx.defer(execute_known_hosts_import);
169            }
170            ConfirmAction::No => {
171                ctx.set_screen(Screen::HostList);
172            }
173            ConfirmAction::Ignored => {}
174        }
175        ctx.effects
176    };
177    effects.apply(app);
178}
179
180pub(super) fn handle_purge_stale_key(app: &mut App, key: KeyEvent) {
181    if !matches!(app.screen, Screen::ConfirmPurgeStale) {
182        return;
183    }
184    let route = route_confirm_key(key);
185    if route == ConfirmAction::Ignored {
186        return;
187    }
188    // Take the payload up front: yes/no both transition away, so the
189    // pending slot must clear in either case to avoid stale reads if
190    // the user opens a fresh purge dialog later.
191    let Some(payload) = app.providers.take_pending_purge() else {
192        // State pruned out from under us. Drop to HostList so the user
193        // is not stranded on an empty purge dialog.
194        app.set_screen(Screen::HostList);
195        return;
196    };
197    let provider = payload.provider;
198    let return_screen = if provider.is_some() {
199        Screen::Providers
200    } else {
201        Screen::HostList
202    };
203    let effects = {
204        let mut ctx = ConfirmCtx {
205            screen: &mut app.screen,
206            effects: Effects::default(),
207        };
208        match route {
209            ConfirmAction::Yes => {
210                // Defer the purge (a whole-App op: deletes hosts, kills tunnels,
211                // reloads). It is the terminal action. moving it past the screen
212                // assignment is safe because it reads no screen state and emits
213                // the only toast either way.
214                ctx.defer(move |app| execute_purge_stale(app, provider.as_deref()));
215                ctx.set_screen(return_screen);
216            }
217            ConfirmAction::No => {
218                ctx.set_screen(return_screen);
219            }
220            ConfirmAction::Ignored => unreachable!(),
221        }
222        ctx.effects
223    };
224    effects.apply(app);
225}
226
227fn execute_purge_stale(app: &mut App, provider: Option<&str>) {
228    let stale = app.hosts_state.ssh_config().stale_hosts();
229    if stale.is_empty() {
230        return;
231    }
232    // Filter by provider if specified.
233    let targets: Vec<(String, u64)> = if let Some(prov) = provider {
234        stale
235            .into_iter()
236            .filter(|(alias, _)| {
237                app.hosts_state
238                    .ssh_config()
239                    .host_entries()
240                    .iter()
241                    .any(|e| e.alias == *alias && e.provider.as_deref() == Some(prov))
242            })
243            .collect()
244    } else {
245        stale
246    };
247    if targets.is_empty() {
248        return;
249    }
250    let config_backup = app.hosts_state.ssh_config().clone();
251    let count = targets.len();
252    for (alias, _) in &targets {
253        app.hosts_state.ssh_config_mut().delete_host(alias);
254    }
255    if let Err(e) = app.hosts_state.ssh_config().write() {
256        app.hosts_state.set_ssh_config(config_backup);
257        app.notify_error(crate::messages::failed_to_save(&e));
258        return;
259    }
260    // Kill active tunnels only after successful write (no rollback needed).
261    for (alias, _) in &targets {
262        if let Some(mut tunnel) = app.tunnels.active_remove(alias) {
263            let _ = tunnel.child.kill();
264            let _ = tunnel.child.wait();
265        }
266    }
267    app.hosts_state.clear_undo();
268    app.update_last_modified();
269    app.reload_hosts();
270    log::debug!(
271        "[purple] purged {} stale host(s){}",
272        count,
273        provider
274            .map(|p| format!(" provider={p}"))
275            .unwrap_or_default()
276    );
277    let msg = if let Some(prov) = provider {
278        let display = crate::providers::provider_display_name(prov);
279        format!(
280            "Removed {} stale {} host{}.",
281            count,
282            display,
283            if count == 1 { "" } else { "s" }
284        )
285    } else {
286        format!(
287            "Removed {} stale host{}.",
288            count,
289            if count == 1 { "" } else { "s" }
290        )
291    };
292    app.notify(msg);
293}
294
295// Stays on `&mut App` (not a context slice). The delete path is cross-cutting
296// and result-driven: it reads `delete_host_undoable`'s Option to branch, then
297// touches hosts_state (undo stack + ssh_config), tunnels, vault cert cleanup,
298// ui and status, and interleaves `update_last_modified`/`reload_hosts` with
299// the subsequent notify/set_screen. Deferring the reload past the success
300// toast could reorder reload's vault-flush error against it, so a slice here
301// would add risk with no clean win.
302pub(super) fn handle_delete_key(app: &mut App, key: KeyEvent) {
303    let Screen::ConfirmDelete { alias } = &app.screen else {
304        return;
305    };
306    let alias = alias.clone();
307    // Use the central confirm-key router so the y/n/Esc contract is uniform
308    // across all confirm dialogs.
309    match route_confirm_key(key) {
310        ConfirmAction::Yes => {
311            let siblings = app.hosts_state.ssh_config().siblings_of(&alias);
312
313            if !siblings.is_empty() {
314                // Multi-alias block: strip only the selected token.
315                // `delete_host_undoable` refuses this case (returning
316                // None) because re-inserting the whole element via
317                // `insert_host_at` cannot reverse a token strip. We
318                // therefore skip the undo stack and surface the event
319                // via a dedicated toast that names the surviving
320                // siblings, so the user knows what did and did not
321                // change on disk.
322                app.hosts_state.ssh_config_mut().delete_host(&alias);
323                if let Err(e) = app.hosts_state.ssh_config().write() {
324                    // Disk write failed: reload from disk to discard
325                    // the in-memory strip so view and storage match.
326                    app.notify_error(crate::messages::failed_to_save(&e));
327                    app.reload_hosts();
328                } else {
329                    if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
330                        let _ = tunnel.child.kill();
331                        let _ = tunnel.child.wait();
332                    }
333                    app.update_last_modified();
334                    app.reload_hosts();
335                    log::debug!(
336                        "[purple] host alias stripped: alias={alias}, {} sibling(s) kept",
337                        siblings.len()
338                    );
339                    app.notify(crate::messages::siblings_stripped(&alias, siblings.len()));
340                }
341            } else if let Some((element, position)) = app
342                .hosts_state
343                .ssh_config_mut()
344                .delete_host_undoable(&alias)
345            {
346                if let Err(e) = app.hosts_state.ssh_config().write() {
347                    // Restore the element on write failure
348                    app.hosts_state
349                        .ssh_config_mut()
350                        .insert_host_at(element, position);
351                    app.notify_error(crate::messages::failed_to_save(&e));
352                } else {
353                    // Stop active tunnel for the deleted host
354                    if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
355                        let _ = tunnel.child.kill();
356                        let _ = tunnel.child.wait();
357                    }
358                    // Clean up cert file if it exists. NotFound is the
359                    // expected case for hosts that never had a cert. Other
360                    // errors are surfaced via the status bar (never via
361                    // eprintln, which would corrupt the ratatui screen).
362                    let mut cert_cleanup_warning: Option<String> = None;
363                    if !crate::demo_flag::is_demo()
364                        && let Ok(cert_path) =
365                            crate::vault_ssh::cert_path_for(app.env().paths(), &alias)
366                    {
367                        match std::fs::remove_file(&cert_path) {
368                            Ok(()) => {}
369                            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
370                            Err(e) => {
371                                cert_cleanup_warning = Some(crate::messages::cert_cleanup_warning(
372                                    &cert_path.display(),
373                                    &e,
374                                ));
375                            }
376                        }
377                    }
378                    app.hosts_state
379                        .undo_stack_mut()
380                        .push(crate::app::DeletedHost { element, position });
381                    if app.hosts_state.undo_stack().len() > 50 {
382                        app.hosts_state.undo_stack_mut().remove(0);
383                    }
384                    app.update_last_modified();
385                    app.reload_hosts();
386                    log::debug!("[purple] host deleted: alias={alias} (undoable)");
387                    if let Some(warning) = cert_cleanup_warning {
388                        app.notify_error(warning);
389                    } else {
390                        app.notify(crate::messages::goodbye_host(&alias));
391                    }
392                }
393            } else {
394                app.notify_warning(crate::messages::host_not_found(&alias));
395            }
396            app.set_screen(Screen::HostList);
397        }
398        ConfirmAction::No => {
399            app.set_screen(Screen::HostList);
400        }
401        ConfirmAction::Ignored => {}
402    }
403}
404
405pub(super) fn handle_vault_sign_key(
406    app: &mut App,
407    key: KeyEvent,
408    events_tx: &mpsc::Sender<AppEvent>,
409) {
410    // Vault Sign is a destructive/material action: signing N certificates
411    // hits Vault, may take time and is hard to reverse. Stray keys must NOT
412    // cancel. use `route_confirm_key` so only y/Y/n/N/Esc are honored.
413    // History: an earlier `_ => app.screen = Screen::HostList` catch-all
414    // could be triggered by any keypress next to `y` (e.g. fat-fingered
415    // `t` or `u`), silently aborting a bulk sign.
416    if !matches!(app.screen, Screen::ConfirmVaultSign) {
417        return;
418    }
419    let route = route_confirm_key(key);
420    if route == ConfirmAction::Ignored {
421        return;
422    }
423    // Take the signable list up front so both yes and no paths leave
424    // the slot clean.
425    let Some(signable) = app.vault.take_pending_sign() else {
426        // State pruned (target host removed during the dialog). Drop to
427        // HostList so the confirm screen is not orphaned.
428        app.set_screen(Screen::HostList);
429        return;
430    };
431    let effects = {
432        let mut ctx = ConfirmCtx {
433            screen: &mut app.screen,
434            effects: Effects::default(),
435        };
436        match route {
437            ConfirmAction::Yes => {
438                // Transition back to the host list and kick off the background
439                // signing loop. The signing loop is the terminal whole-App op
440                // (touches vault, env, status), so it runs deferred after the
441                // screen transition. Its first action is the progress toast,
442                // with no intervening toast.
443                ctx.set_screen(Screen::HostList);
444                let tx = events_tx.clone();
445                ctx.defer(move |app| start_vault_bulk_sign(app, signable, &tx));
446            }
447            ConfirmAction::No => {
448                ctx.set_screen(Screen::HostList);
449            }
450            ConfirmAction::Ignored => unreachable!(),
451        }
452        ctx.effects
453    };
454    effects.apply(app);
455}
456
457/// Start the background vault bulk sign loop with fast-fail, progress, TOCTOU
458/// coordination and cancellation. Stores the JoinHandle on App for clean exit.
459fn start_vault_bulk_sign(
460    app: &mut App,
461    signable: Vec<crate::vault_ssh::VaultSignTarget>,
462    events_tx: &mpsc::Sender<AppEvent>,
463) {
464    let total = signable.len();
465    if total == 0 {
466        return;
467    }
468    app.notify_progress(crate::messages::vault_signing_progress(
469        crate::animation::SPINNER_FRAMES[0],
470        0,
471        total,
472        "",
473    ));
474
475    let cancel = Arc::new(AtomicBool::new(false));
476    app.vault.set_signing_cancel(cancel.clone());
477
478    let in_flight = app.vault.sign_in_flight().clone();
479    let tx = events_tx.clone();
480    // Capture the resolved environment before spawning: a worker thread does
481    // not inherit the parent's `Env`, so the PATH/paths must move in.
482    let env = std::sync::Arc::clone(&app.env);
483    let spawn_result = std::thread::Builder::new()
484        .name("vault-bulk-sign".into())
485        .spawn(move || {
486            let mut signed = 0u32;
487            let mut failed = 0u32;
488            let mut skipped = 0u32;
489            let mut consecutive_failures = 0usize;
490            let mut first_error: Option<String> = None;
491            let mut aborted_message: Option<String> = None;
492
493            for (idx, target) in signable.iter().enumerate() {
494                let crate::vault_ssh::VaultSignTarget {
495                    alias,
496                    role,
497                    certificate_file: cert_file,
498                    pubkey,
499                    vault_addr,
500                } = target;
501                if cancel.load(Ordering::Relaxed) {
502                    break;
503                }
504                let done = idx + 1;
505
506                // TOCTOU: skip host if another thread already has it in-flight.
507                // Otherwise mark it in-flight for the duration of this iteration.
508                {
509                    // If the mutex is poisoned a worker thread panicked while holding
510                    // the lock. Recover the inner value without clearing. clearing
511                    // the whole set would make every in-flight alias simultaneously
512                    // eligible for re-signing, risking duplicate cert writes.
513                    let mut set = match in_flight.lock() {
514                        Ok(g) => g,
515                        Err(p) => p.into_inner(),
516                    };
517                    if !set.insert(alias.clone()) {
518                        skipped += 1;
519                        let _ = tx.send(AppEvent::VaultSignProgress {
520                            alias: alias.clone(),
521                            done,
522                            total,
523                        });
524                        continue;
525                    }
526                }
527
528                let _ = tx.send(AppEvent::VaultSignProgress {
529                    alias: alias.clone(),
530                    done,
531                    total,
532                });
533
534                let cert_path =
535                    match crate::vault_ssh::resolve_cert_path(env.paths(), alias, cert_file) {
536                        Ok(p) => p,
537                        Err(e) => {
538                            failed += 1;
539                            consecutive_failures += 1;
540                            let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
541                            if first_error.is_none() {
542                                first_error = Some(scrubbed);
543                            }
544                            remove_in_flight(&in_flight, alias);
545                            if consecutive_failures >= 3 {
546                                aborted_message = Some(crate::messages::vault_signing_aborted(
547                                    failed,
548                                    first_error.as_deref(),
549                                ));
550                                break;
551                            }
552                            continue;
553                        }
554                    };
555                let status = crate::vault_ssh::check_cert_validity(&env, &cert_path);
556                if !crate::vault_ssh::needs_renewal(&status) {
557                    skipped += 1;
558                    consecutive_failures = 0;
559                    remove_in_flight(&in_flight, alias);
560                    continue;
561                }
562
563                let sign_result = crate::vault_ssh::sign_certificate(
564                    &env,
565                    role,
566                    pubkey,
567                    alias,
568                    vault_addr.as_deref(),
569                );
570                // Always clean up in_flight for this alias before handling the
571                // result. Using a single cleanup point (rather than per-arm)
572                // prevents orphaned aliases when new control flow is added.
573                remove_in_flight(&in_flight, alias);
574                match sign_result {
575                    Ok(_) => {
576                        let _ = tx.send(AppEvent::VaultSignResult {
577                            alias: alias.clone(),
578                            certificate_file: cert_file.clone(),
579                            success: true,
580                            message: String::new(),
581                        });
582                        signed += 1;
583                        consecutive_failures = 0;
584                    }
585                    Err(e) => {
586                        let raw = e.to_string();
587                        let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
588                        if first_error.is_none() {
589                            first_error = Some(scrubbed.clone());
590                        }
591                        let _ = tx.send(AppEvent::VaultSignResult {
592                            alias: alias.clone(),
593                            certificate_file: cert_file.clone(),
594                            success: false,
595                            message: scrubbed,
596                        });
597                        failed += 1;
598                        consecutive_failures += 1;
599                        if consecutive_failures >= 3 {
600                            aborted_message = Some(crate::messages::vault_signing_aborted(
601                                failed,
602                                first_error.as_deref(),
603                            ));
604                            break;
605                        }
606                    }
607                }
608            }
609
610            let cancelled = cancel.load(Ordering::Relaxed);
611            let _ = tx.send(AppEvent::VaultSignAllDone {
612                signed,
613                failed,
614                skipped,
615                cancelled,
616                aborted_message,
617                first_error,
618            });
619        });
620    match spawn_result {
621        Ok(handle) => {
622            log::info!("[purple] vault sign thread: spawned");
623            app.vault.set_sign_thread(handle);
624        }
625        Err(e) => {
626            // Spawn failed (e.g. OS thread limit). Clear the cancel flag and
627            // surface the error. otherwise the status bar is stuck at
628            // "Signing 0/N" with no way for the user to recover.
629            log::warn!("[purple] vault sign thread: spawn failed: {}", e);
630            let _ = app.vault.finalize_signing_run();
631            app.notify_error(crate::messages::vault_spawn_failed(&e));
632        }
633    }
634}
635
636pub(super) fn remove_in_flight(
637    set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
638    alias: &str,
639) {
640    // On mutex poison, recover the inner value and remove only the target alias.
641    // Do NOT clear the entire set. other in-flight aliases are still owned by
642    // live worker iterations and clearing them would allow duplicate signs.
643    let mut guard = match set.lock() {
644        Ok(g) => g,
645        Err(p) => p.into_inner(),
646    };
647    guard.remove(alias);
648}
649
650pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
651    let mut ctx = HostKeyResetCtx {
652        ui: &mut app.ui,
653        status: &mut app.status_center,
654        screen: &mut app.screen,
655        demo_mode: app.demo_mode,
656    };
657    let Screen::ConfirmHostKeyReset {
658        alias,
659        hostname,
660        known_hosts_path,
661        askpass,
662    } = &*ctx.screen
663    else {
664        return;
665    };
666    let alias = alias.clone();
667    let hostname = hostname.clone();
668    let known_hosts_path = known_hosts_path.clone();
669    let askpass = askpass.clone();
670    // Host key reset wipes the host's known_hosts entry. uniform y/n/Esc
671    // contract via the central router so stray keys cannot trigger it.
672    match route_confirm_key(key) {
673        ConfirmAction::Yes => {
674            let output = std::process::Command::new("ssh-keygen")
675                .arg("-R")
676                .arg(&hostname)
677                .arg("-f")
678                .arg(&known_hosts_path)
679                .output();
680
681            match output {
682                Ok(result) if result.status.success() => {
683                    ctx.notify(crate::messages::removed_host_key(&hostname));
684                    if ctx.demo_mode {
685                        ctx.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
686                    } else {
687                        ctx.ui.queue_connect(alias, askpass);
688                    }
689                }
690                Ok(result) => {
691                    let stderr = String::from_utf8_lossy(&result.stderr);
692                    ctx.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
693                }
694                Err(e) => {
695                    ctx.notify_error(crate::messages::ssh_keygen_failed(&e));
696                }
697            }
698            ctx.set_screen(Screen::HostList);
699        }
700        ConfirmAction::No => {
701            ctx.set_screen(Screen::HostList);
702        }
703        ConfirmAction::Ignored => {}
704    }
705}
706
707/// A confirmed container action plus its target(s). Single-container
708/// confirms carry one target; stack and host-wide confirms carry many.
709struct ContainerConfirm {
710    alias: String,
711    targets: Vec<(String, String)>,
712    action: crate::containers::ContainerAction,
713}
714
715/// Shared y/n/Esc routing for every container action confirm. Yes
716/// queues the action for each target then drops to the host list; No
717/// and Esc drop without side effects; anything else is ignored.
718fn apply_container_confirm(
719    ctx: &mut ContainerConfirmCtx,
720    key: KeyEvent,
721    confirm: ContainerConfirm,
722) {
723    match route_confirm_key(key) {
724        ConfirmAction::Yes => {
725            for (container_id, container_name) in confirm.targets {
726                queue_container_action(
727                    ctx,
728                    confirm.alias.clone(),
729                    container_id,
730                    container_name,
731                    confirm.action,
732                );
733            }
734            ctx.set_screen(Screen::HostList);
735        }
736        ConfirmAction::No => {
737            ctx.set_screen(Screen::HostList);
738        }
739        ConfirmAction::Ignored => {}
740    }
741}
742
743/// Borrow the disjoint App fields every container-action confirm needs.
744fn container_confirm_ctx(app: &mut App) -> ContainerConfirmCtx<'_> {
745    ContainerConfirmCtx {
746        container_state: &mut app.container_state,
747        hosts: &app.hosts_state,
748        screen: &mut app.screen,
749    }
750}
751
752/// Confirm handler for `K` (kick = restart): restart a single container.
753pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
754    let mut ctx = container_confirm_ctx(app);
755    let Screen::ConfirmContainerRestart {
756        alias,
757        container_id,
758        container_name,
759        ..
760    } = &*ctx.screen
761    else {
762        return;
763    };
764    let confirm = ContainerConfirm {
765        alias: alias.clone(),
766        targets: vec![(container_id.clone(), container_name.clone())],
767        action: crate::containers::ContainerAction::Restart,
768    };
769    apply_container_confirm(&mut ctx, key, confirm);
770}
771
772/// Confirm handler for `S` (stop): stop a single container.
773pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
774    let mut ctx = container_confirm_ctx(app);
775    let Screen::ConfirmContainerStop {
776        alias,
777        container_id,
778        container_name,
779        ..
780    } = &*ctx.screen
781    else {
782        return;
783    };
784    let confirm = ContainerConfirm {
785        alias: alias.clone(),
786        targets: vec![(container_id.clone(), container_name.clone())],
787        action: crate::containers::ContainerAction::Stop,
788    };
789    apply_container_confirm(&mut ctx, key, confirm);
790}
791
792/// Confirm handler for `Ctrl-K` (stack kick): restart every member of a
793/// compose stack. The drain queue processes one request per tick, so the
794/// restarts run sequentially.
795pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
796    bulk_confirm_key(
797        app,
798        key,
799        Screen::ConfirmStackRestart,
800        crate::containers::ContainerAction::Restart,
801    );
802}
803
804/// Confirm handler for `K` on a host-divider row: restart every running
805/// container on the host, ignoring compose-project boundaries.
806pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
807    bulk_confirm_key(
808        app,
809        key,
810        Screen::ConfirmHostRestartAll,
811        crate::containers::ContainerAction::Restart,
812    );
813}
814
815/// Confirm handler for `S` on a host-divider row: stop every running
816/// container on the host.
817pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
818    bulk_confirm_key(
819        app,
820        key,
821        Screen::ConfirmHostStopAll,
822        crate::containers::ContainerAction::Stop,
823    );
824}
825
826/// Shared dispatcher for the three bulk container confirm dialogs.
827/// `expected_screen` guards against firing the wrong action if the
828/// screen has drifted from `pending_bulk_confirm`. An ignored key
829/// leaves the payload in place so the dialog stays open; yes/no take
830/// the payload because `apply_container_confirm` transitions away.
831fn bulk_confirm_key(
832    app: &mut App,
833    key: KeyEvent,
834    expected_screen: Screen,
835    action: crate::containers::ContainerAction,
836) {
837    if app.screen != expected_screen {
838        return;
839    }
840    if route_confirm_key(key) == ConfirmAction::Ignored {
841        return;
842    }
843    let Some(payload) = app.containers_overview.take_pending_bulk_confirm() else {
844        // State pruned (host removed via reload_hosts while the dialog was
845        // open). Drop to HostList so the user is not stuck on an empty
846        // confirm screen with a non-functional Esc.
847        app.set_screen(Screen::HostList);
848        return;
849    };
850    let targets: Vec<(String, String)> = payload
851        .members
852        .iter()
853        .map(|m| (m.container_id.clone(), m.container_name.clone()))
854        .collect();
855    let confirm = ContainerConfirm {
856        alias: payload.alias,
857        targets,
858        action,
859    };
860    let mut ctx = container_confirm_ctx(app);
861    apply_container_confirm(&mut ctx, key, confirm);
862}
863
864fn queue_container_action(
865    ctx: &mut ContainerConfirmCtx,
866    alias: String,
867    container_id: String,
868    container_name: String,
869    action: crate::containers::ContainerAction,
870) {
871    let Some(entry) = ctx.container_state.cache_entry(&alias) else {
872        log::debug!(
873            "[purple] container_action: queue aborted, no cache for alias={}",
874            alias
875        );
876        return;
877    };
878    let runtime = entry.runtime;
879    let askpass = ctx
880        .hosts
881        .list()
882        .iter()
883        .find(|h| h.alias == alias)
884        .and_then(|h| h.askpass.clone());
885    log::info!(
886        "[purple] container_action queued: alias={} id={} action={:?}",
887        alias,
888        container_id,
889        action
890    );
891    ctx.container_state
892        .queue_action(crate::app::ContainerActionRequest {
893            alias,
894            askpass,
895            runtime,
896            container_id,
897            container_name,
898            action,
899        });
900}
901
902/// Confirm for the `p` push action from the Keys tab. Stakes test:
903/// pushing modifies remote `authorized_keys`, so the footer uses
904/// action verbs (`push` / `keep`) and we only accept y/n/Esc.
905pub(super) fn handle_key_push_key(
906    app: &mut App,
907    key: KeyEvent,
908    events_tx: &mpsc::Sender<AppEvent>,
909) {
910    let effects = {
911        let mut ctx = KeyPushConfirmCtx {
912            keys: &mut app.keys,
913            screen: &mut app.screen,
914            effects: Effects::default(),
915        };
916        match route_confirm_key(key) {
917            ConfirmAction::Yes => {
918                let key_index = match &*ctx.screen {
919                    Screen::ConfirmKeyPush { key_index } => *key_index,
920                    _ => return,
921                };
922                let aliases = std::mem::take(&mut ctx.keys.push_mut().committed);
923                ctx.set_screen(Screen::HostList);
924                // The push worker is the terminal whole-App op (reads the key
925                // list and config path, spawns a thread). it runs deferred
926                // after the screen transition. its first observable action is
927                // the guard/progress toast, with no intervening toast.
928                let tx = events_tx.clone();
929                ctx.defer(move |app| start_key_push(app, key_index, aliases, &tx));
930            }
931            ConfirmAction::No => {
932                // Return to the picker with the selection still intact so the
933                // user can refine it.
934                let key_index = match &*ctx.screen {
935                    Screen::ConfirmKeyPush { key_index } => *key_index,
936                    _ => return,
937                };
938                ctx.keys.push_mut().committed.clear();
939                ctx.set_screen(Screen::KeyPushPicker { key_index });
940            }
941            ConfirmAction::Ignored => {}
942        }
943        ctx.effects
944    };
945    effects.apply(app);
946}
947
948/// Confirm dialog for `Screen::ConfirmRunSnippet`. On Yes, run the chosen
949/// snippet against the committed hosts (this sets the next screen itself via
950/// `run_flow_snippet`). On No/Esc, return to the host picker with the selection
951/// intact. y/n/Esc are the only effective inputs (`route_confirm_key`).
952pub(super) fn handle_run_snippet_confirm_key(
953    app: &mut App,
954    key: KeyEvent,
955    events_tx: &mpsc::Sender<AppEvent>,
956) {
957    match route_confirm_key(key) {
958        ConfirmAction::Yes => {
959            super::snippet::run_flow_snippet(app, events_tx);
960        }
961        ConfirmAction::No => {
962            app.set_screen(Screen::SnippetHostPicker);
963        }
964        ConfirmAction::Ignored => {}
965    }
966}
967
968/// Spawn the background push worker. Reads the pubkey from disk on the
969/// main thread (cheap) so we surface an early error toast before
970/// committing to the run. On read failure we abort and stay on
971/// HostList. Refuses to start a second push while a first is still in
972/// flight (`expected_count > 0`); the user must press Esc to cancel
973/// before triggering another run.
974fn start_key_push(
975    app: &mut App,
976    key_index: usize,
977    aliases: Vec<String>,
978    events_tx: &mpsc::Sender<AppEvent>,
979) {
980    // Refuse second push while a previous run still has live state OR a
981    // worker handle that has not been observed to finish. Belt-and-braces:
982    // expected_count protects the in-flight branch, worker.is_finished()
983    // protects the post-cancel branch where the worker is still draining
984    // but its results no longer count toward any expected total.
985    if app.keys.push().expected_count > 0
986        || app
987            .keys
988            .push()
989            .worker
990            .as_ref()
991            .is_some_and(|h| !h.is_finished())
992    {
993        log::debug!(
994            "[purple] key_push: rejected second push, run already in progress ({} of {})",
995            app.keys.push().results.len(),
996            app.keys.push().expected_count
997        );
998        app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
999        return;
1000    }
1001    if aliases.is_empty() {
1002        log::debug!("[purple] key_push: rejected, no aliases committed");
1003        app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
1004        return;
1005    }
1006    let Some(key_info) = app.keys.list().get(key_index).cloned() else {
1007        return;
1008    };
1009    if key_info.is_certificate {
1010        app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
1011        return;
1012    }
1013    let pub_path = crate::key_push::pubkey_path_for(app.env.paths(), &key_info.display_path);
1014    let raw = match crate::key_push::read_pubkey_file(&pub_path) {
1015        Ok(s) => s,
1016        Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
1017            log::warn!(
1018                "[config] key_push: pubkey too large path={} bytes={}",
1019                pub_path.display(),
1020                n
1021            );
1022            app.notify_error(crate::messages::key_push_pubkey_too_large(
1023                &key_info.name,
1024                n,
1025            ));
1026            return;
1027        }
1028        Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
1029            log::warn!(
1030                "[config] key_push: pubkey not a regular file path={}",
1031                pub_path.display()
1032            );
1033            app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
1034            return;
1035        }
1036        Err(_) => {
1037            // Other validation variants are unreachable here (read_pubkey_file
1038            // only returns TooLarge / NotARegularFile / IO collapsed into
1039            // NotARegularFile). Defensive fallthrough.
1040            app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
1041            return;
1042        }
1043    };
1044    let pubkey = match crate::key_push::validate_pubkey(&raw) {
1045        Ok(s) => s,
1046        Err(err) => {
1047            let detail = match &err {
1048                crate::key_push::PubkeyValidationError::Empty => "file is empty",
1049                crate::key_push::PubkeyValidationError::MultiLine => {
1050                    "must be a single line; multi-line input is rejected"
1051                }
1052                crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
1053                    "key algorithm not allowed for static push"
1054                }
1055                crate::key_push::PubkeyValidationError::MalformedBase64 => {
1056                    "base64 key body did not parse"
1057                }
1058                _ => "unexpected format",
1059            };
1060            log::warn!(
1061                "[config] key_push: invalid pubkey path={} err={:?}",
1062                pub_path.display(),
1063                err
1064            );
1065            app.notify_error(crate::messages::key_push_invalid_pubkey(
1066                &key_info.name,
1067                detail,
1068            ));
1069            return;
1070        }
1071    };
1072
1073    // Reset accumulators and start a new run.
1074    let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
1075
1076    app.notify_progress(crate::messages::key_push_in_progress(
1077        &key_info.name,
1078        aliases.len(),
1079    ));
1080
1081    let config_path = app.hosts_state.ssh_config().path.clone();
1082    let tx = events_tx.clone();
1083    let pubkey_payload = pubkey;
1084    let handle = std::thread::Builder::new()
1085        .name("key-push".into())
1086        .spawn(move || {
1087            for alias in aliases {
1088                if cancel.load(Ordering::Relaxed) {
1089                    break;
1090                }
1091                let outcome =
1092                    crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
1093                let _ = tx.send(AppEvent::KeyPushResult {
1094                    run_id,
1095                    result: crate::key_push::KeyPushResult { alias, outcome },
1096                });
1097            }
1098        });
1099    match handle {
1100        Ok(h) => {
1101            app.keys.push_mut().worker = Some(h);
1102        }
1103        Err(e) => {
1104            log::error!("[purple] key_push: failed to spawn worker: {}", e);
1105            // Drop the progress toast through the status-center invariant
1106            // so the user does not see "Pushing..." stuck under the
1107            // failure message.
1108            app.status_center.clear_sticky_status();
1109            app.notify_error(crate::messages::key_push_thread_spawn_failed());
1110            app.keys.push_mut().clear_inflight_state();
1111        }
1112    }
1113}
1114
1115#[cfg(test)]
1116mod key_push_confirm_tests {
1117    //! Coverage for the gate functions wrapping the push-worker spawn.
1118    //! Every test exercises a guard path (already-running, missing pubkey,
1119    //! certificate key, empty selection, return-to-picker) and asserts the
1120    //! observable state. The happy-spawn path is intentionally not unit
1121    //! tested here because it forks an ssh subprocess; that path is
1122    //! covered by the event-loop tests against the run-completion flow.
1123    use super::*;
1124    use crate::ssh_config::model::SshConfigFile;
1125    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1126
1127    fn make_app() -> (App, std::path::PathBuf) {
1128        let scratch = tempfile::tempdir().expect("tempdir").keep();
1129        let config = SshConfigFile {
1130            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
1131            path: scratch.join("test_config"),
1132            crlf: false,
1133            bom: false,
1134        };
1135        let mut app = App::new(config);
1136        // Seed a non-cert key whose .pub file lives in the scratch dir so
1137        // `read_pubkey_file` succeeds via the absolute display path.
1138        let pub_path = scratch.join("id_test.pub");
1139        std::fs::write(
1140            &pub_path,
1141            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
1142        )
1143        .unwrap();
1144        app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1145            name: "id_test".into(),
1146            display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
1147            key_type: "ED25519".into(),
1148            bits: "256".into(),
1149            fingerprint: String::new(),
1150            comment: "test@host".into(),
1151            linked_hosts: vec![],
1152            bishop_art: String::new(),
1153            strength_score: 95,
1154            encrypted: false,
1155            agent_loaded: false,
1156            is_certificate: false,
1157            mtime_ts: None,
1158        });
1159        (app, scratch)
1160    }
1161
1162    fn k(code: KeyCode) -> KeyEvent {
1163        KeyEvent::new(code, KeyModifiers::NONE)
1164    }
1165
1166    #[test]
1167    fn n_returns_to_picker_with_key_index_preserved() {
1168        let (mut app, _scratch) = make_app();
1169        app.keys.push_mut().committed = vec!["h1".into()];
1170        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1171        let (tx, _rx) = mpsc::channel();
1172        handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
1173        match app.screen {
1174            Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
1175            ref other => panic!("expected KeyPushPicker, got {:?}", other),
1176        }
1177        assert!(
1178            app.keys.push().committed.is_empty(),
1179            "n should drop the frozen selection"
1180        );
1181    }
1182
1183    #[test]
1184    fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
1185        let (mut app, _scratch) = make_app();
1186        app.keys.push_mut().committed = vec!["h1".into()];
1187        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1188        let (tx, _rx) = mpsc::channel();
1189        handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
1190        assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
1191    }
1192
1193    #[test]
1194    fn start_rejects_when_a_previous_run_is_still_in_flight() {
1195        let (mut app, _scratch) = make_app();
1196        app.keys.push_mut().expected_count = 2;
1197        app.keys
1198            .push_mut()
1199            .results
1200            .push(crate::key_push::KeyPushResult {
1201                alias: "h1".into(),
1202                outcome: crate::key_push::KeyPushOutcome::Appended,
1203            });
1204        let (tx, _rx) = mpsc::channel();
1205        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1206        assert_eq!(
1207            app.keys.push().expected_count,
1208            2,
1209            "guard must not reset in-flight state"
1210        );
1211        let toast = app.status_center.toast().expect("toast set");
1212        assert!(
1213            toast.text.contains("already running"),
1214            "expected 'already running' warning, got: {}",
1215            toast.text
1216        );
1217    }
1218
1219    #[test]
1220    fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1221        let (mut app, _scratch) = make_app();
1222        let (tx, _rx) = mpsc::channel();
1223        start_key_push(&mut app, 0, Vec::new(), &tx);
1224        assert_eq!(app.keys.push().expected_count, 0);
1225        assert!(app.keys.push().worker.is_none());
1226        let toast = app.status_center.toast().expect("toast set");
1227        assert!(toast.is_error());
1228    }
1229
1230    #[test]
1231    fn start_rejects_certificate_key() {
1232        let (mut app, _scratch) = make_app();
1233        app.keys.list_mut()[0].is_certificate = true;
1234        let (tx, _rx) = mpsc::channel();
1235        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1236        assert_eq!(app.keys.push().expected_count, 0);
1237        assert!(app.keys.push().worker.is_none());
1238        let toast = app.status_center.toast().expect("toast set");
1239        assert!(toast.is_error());
1240        assert!(toast.text.contains("Certificates"));
1241    }
1242
1243    #[test]
1244    fn start_rejects_missing_pubkey_file() {
1245        let (mut app, _scratch) = make_app();
1246        app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1247        let (tx, _rx) = mpsc::channel();
1248        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1249        assert_eq!(app.keys.push().expected_count, 0);
1250        let toast = app.status_center.toast().expect("toast set");
1251        assert!(toast.is_error());
1252    }
1253
1254    #[test]
1255    fn start_rejects_invalid_pubkey_content() {
1256        let (mut app, scratch) = make_app();
1257        // Multi-line pubkey: the canonical command-injection PoC. Must be
1258        // rejected without spawning the worker.
1259        let pub_path = scratch.join("id_bad.pub");
1260        std::fs::write(
1261            &pub_path,
1262            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1263        )
1264        .unwrap();
1265        app.keys.list_mut()[0].display_path =
1266            pub_path.with_extension("").to_string_lossy().into_owned();
1267        app.keys.list_mut()[0].name = "id_bad".into();
1268        let (tx, _rx) = mpsc::channel();
1269        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1270        assert_eq!(app.keys.push().expected_count, 0);
1271        assert!(app.keys.push().worker.is_none());
1272        let toast = app.status_center.toast().expect("toast set");
1273        assert!(toast.is_error());
1274        assert!(toast.text.contains("validation"));
1275    }
1276
1277    #[test]
1278    fn run_snippet_confirm_no_returns_to_picker() {
1279        let scratch = tempfile::tempdir().expect("tempdir").keep();
1280        let config = SshConfigFile {
1281            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
1282            path: scratch.join("cfg"),
1283            crlf: false,
1284            bom: false,
1285        };
1286        let mut app = App::new(config);
1287        app.set_screen(Screen::ConfirmRunSnippet);
1288        let (tx, _rx) = mpsc::channel();
1289        handle_run_snippet_confirm_key(
1290            &mut app,
1291            KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
1292            &tx,
1293        );
1294        assert!(matches!(app.screen, Screen::SnippetHostPicker));
1295    }
1296
1297    #[test]
1298    fn run_snippet_confirm_yes_with_params_opens_param_form() {
1299        let scratch = tempfile::tempdir().expect("tempdir").keep();
1300        let config = SshConfigFile {
1301            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
1302            path: scratch.join("cfg"),
1303            crlf: false,
1304            bom: false,
1305        };
1306        let mut app = App::new(config);
1307        // A parametrised snippet: Yes prompts for params rather than spawning ssh.
1308        app.snippets.set_flow_snippet(Some(crate::snippet::Snippet {
1309            name: "deploy".into(),
1310            command: "echo {{msg}}".into(),
1311            description: String::new(),
1312        }));
1313        app.snippets.set_flow_targets(vec!["h1".into()]);
1314        app.set_screen(Screen::ConfirmRunSnippet);
1315        let (tx, _rx) = mpsc::channel();
1316        handle_run_snippet_confirm_key(
1317            &mut app,
1318            KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
1319            &tx,
1320        );
1321        assert!(matches!(app.screen, Screen::SnippetParamForm));
1322        assert!(app.snippets.form_return_to_tab());
1323    }
1324}