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                        if 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 =
372                                        Some(crate::messages::cert_cleanup_warning(
373                                            &cert_path.display(),
374                                            &e,
375                                        ));
376                                }
377                            }
378                        }
379                    }
380                    app.hosts_state
381                        .undo_stack_mut()
382                        .push(crate::app::DeletedHost { element, position });
383                    if app.hosts_state.undo_stack().len() > 50 {
384                        app.hosts_state.undo_stack_mut().remove(0);
385                    }
386                    app.update_last_modified();
387                    app.reload_hosts();
388                    log::debug!("[purple] host deleted: alias={alias} (undoable)");
389                    if let Some(warning) = cert_cleanup_warning {
390                        app.notify_error(warning);
391                    } else {
392                        app.notify(crate::messages::goodbye_host(&alias));
393                    }
394                }
395            } else {
396                app.notify_warning(crate::messages::host_not_found(&alias));
397            }
398            app.set_screen(Screen::HostList);
399        }
400        ConfirmAction::No => {
401            app.set_screen(Screen::HostList);
402        }
403        ConfirmAction::Ignored => {}
404    }
405}
406
407pub(super) fn handle_vault_sign_key(
408    app: &mut App,
409    key: KeyEvent,
410    events_tx: &mpsc::Sender<AppEvent>,
411) {
412    // Vault Sign is a destructive/material action: signing N certificates
413    // hits Vault, may take time and is hard to reverse. Stray keys must NOT
414    // cancel. use `route_confirm_key` so only y/Y/n/N/Esc are honored.
415    // History: an earlier `_ => app.screen = Screen::HostList` catch-all
416    // could be triggered by any keypress next to `y` (e.g. fat-fingered
417    // `t` or `u`), silently aborting a bulk sign.
418    if !matches!(app.screen, Screen::ConfirmVaultSign) {
419        return;
420    }
421    let route = route_confirm_key(key);
422    if route == ConfirmAction::Ignored {
423        return;
424    }
425    // Take the signable list up front so both yes and no paths leave
426    // the slot clean.
427    let Some(signable) = app.vault.take_pending_sign() else {
428        // State pruned (target host removed during the dialog). Drop to
429        // HostList so the confirm screen is not orphaned.
430        app.set_screen(Screen::HostList);
431        return;
432    };
433    let effects = {
434        let mut ctx = ConfirmCtx {
435            screen: &mut app.screen,
436            effects: Effects::default(),
437        };
438        match route {
439            ConfirmAction::Yes => {
440                // Transition back to the host list and kick off the background
441                // signing loop. The signing loop is the terminal whole-App op
442                // (touches vault, env, status), so it runs deferred after the
443                // screen transition. Its first action is the progress toast,
444                // with no intervening toast.
445                ctx.set_screen(Screen::HostList);
446                let tx = events_tx.clone();
447                ctx.defer(move |app| start_vault_bulk_sign(app, signable, &tx));
448            }
449            ConfirmAction::No => {
450                ctx.set_screen(Screen::HostList);
451            }
452            ConfirmAction::Ignored => unreachable!(),
453        }
454        ctx.effects
455    };
456    effects.apply(app);
457}
458
459/// Start the background vault bulk sign loop with fast-fail, progress, TOCTOU
460/// coordination and cancellation. Stores the JoinHandle on App for clean exit.
461fn start_vault_bulk_sign(
462    app: &mut App,
463    signable: Vec<crate::vault_ssh::VaultSignTarget>,
464    events_tx: &mpsc::Sender<AppEvent>,
465) {
466    let total = signable.len();
467    if total == 0 {
468        return;
469    }
470    app.notify_progress(crate::messages::vault_signing_progress(
471        crate::animation::SPINNER_FRAMES[0],
472        0,
473        total,
474        "",
475    ));
476
477    let cancel = Arc::new(AtomicBool::new(false));
478    app.vault.set_signing_cancel(cancel.clone());
479
480    let in_flight = app.vault.sign_in_flight().clone();
481    let tx = events_tx.clone();
482    // Capture the resolved environment before spawning: a worker thread does
483    // not inherit the parent's `Env`, so the PATH/paths must move in.
484    let env = std::sync::Arc::clone(&app.env);
485    let spawn_result = std::thread::Builder::new()
486        .name("vault-bulk-sign".into())
487        .spawn(move || {
488            let mut signed = 0u32;
489            let mut failed = 0u32;
490            let mut skipped = 0u32;
491            let mut consecutive_failures = 0usize;
492            let mut first_error: Option<String> = None;
493            let mut aborted_message: Option<String> = None;
494
495            for (idx, target) in signable.iter().enumerate() {
496                let crate::vault_ssh::VaultSignTarget {
497                    alias,
498                    role,
499                    certificate_file: cert_file,
500                    pubkey,
501                    vault_addr,
502                } = target;
503                if cancel.load(Ordering::Relaxed) {
504                    break;
505                }
506                let done = idx + 1;
507
508                // TOCTOU: skip host if another thread already has it in-flight.
509                // Otherwise mark it in-flight for the duration of this iteration.
510                {
511                    // If the mutex is poisoned a worker thread panicked while holding
512                    // the lock. Recover the inner value without clearing. clearing
513                    // the whole set would make every in-flight alias simultaneously
514                    // eligible for re-signing, risking duplicate cert writes.
515                    let mut set = match in_flight.lock() {
516                        Ok(g) => g,
517                        Err(p) => p.into_inner(),
518                    };
519                    if !set.insert(alias.clone()) {
520                        skipped += 1;
521                        let _ = tx.send(AppEvent::VaultSignProgress {
522                            alias: alias.clone(),
523                            done,
524                            total,
525                        });
526                        continue;
527                    }
528                }
529
530                let _ = tx.send(AppEvent::VaultSignProgress {
531                    alias: alias.clone(),
532                    done,
533                    total,
534                });
535
536                let cert_path =
537                    match crate::vault_ssh::resolve_cert_path(env.paths(), alias, cert_file) {
538                        Ok(p) => p,
539                        Err(e) => {
540                            failed += 1;
541                            consecutive_failures += 1;
542                            let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
543                            if first_error.is_none() {
544                                first_error = Some(scrubbed);
545                            }
546                            remove_in_flight(&in_flight, alias);
547                            if consecutive_failures >= 3 {
548                                aborted_message = Some(crate::messages::vault_signing_aborted(
549                                    failed,
550                                    first_error.as_deref(),
551                                ));
552                                break;
553                            }
554                            continue;
555                        }
556                    };
557                let status = crate::vault_ssh::check_cert_validity(&env, &cert_path);
558                if !crate::vault_ssh::needs_renewal(&status) {
559                    skipped += 1;
560                    consecutive_failures = 0;
561                    remove_in_flight(&in_flight, alias);
562                    continue;
563                }
564
565                let sign_result = crate::vault_ssh::sign_certificate(
566                    &env,
567                    role,
568                    pubkey,
569                    alias,
570                    vault_addr.as_deref(),
571                );
572                // Always clean up in_flight for this alias before handling the
573                // result. Using a single cleanup point (rather than per-arm)
574                // prevents orphaned aliases when new control flow is added.
575                remove_in_flight(&in_flight, alias);
576                match sign_result {
577                    Ok(_) => {
578                        let _ = tx.send(AppEvent::VaultSignResult {
579                            alias: alias.clone(),
580                            certificate_file: cert_file.clone(),
581                            success: true,
582                            message: String::new(),
583                        });
584                        signed += 1;
585                        consecutive_failures = 0;
586                    }
587                    Err(e) => {
588                        let raw = e.to_string();
589                        let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
590                        if first_error.is_none() {
591                            first_error = Some(scrubbed.clone());
592                        }
593                        let _ = tx.send(AppEvent::VaultSignResult {
594                            alias: alias.clone(),
595                            certificate_file: cert_file.clone(),
596                            success: false,
597                            message: scrubbed,
598                        });
599                        failed += 1;
600                        consecutive_failures += 1;
601                        if consecutive_failures >= 3 {
602                            aborted_message = Some(crate::messages::vault_signing_aborted(
603                                failed,
604                                first_error.as_deref(),
605                            ));
606                            break;
607                        }
608                    }
609                }
610            }
611
612            let cancelled = cancel.load(Ordering::Relaxed);
613            let _ = tx.send(AppEvent::VaultSignAllDone {
614                signed,
615                failed,
616                skipped,
617                cancelled,
618                aborted_message,
619                first_error,
620            });
621        });
622    match spawn_result {
623        Ok(handle) => {
624            log::info!("[purple] vault sign thread: spawned");
625            app.vault.set_sign_thread(handle);
626        }
627        Err(e) => {
628            // Spawn failed (e.g. OS thread limit). Clear the cancel flag and
629            // surface the error. otherwise the status bar is stuck at
630            // "Signing 0/N" with no way for the user to recover.
631            log::warn!("[purple] vault sign thread: spawn failed: {}", e);
632            let _ = app.vault.finalize_signing_run();
633            app.notify_error(crate::messages::vault_spawn_failed(&e));
634        }
635    }
636}
637
638pub(super) fn remove_in_flight(
639    set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
640    alias: &str,
641) {
642    // On mutex poison, recover the inner value and remove only the target alias.
643    // Do NOT clear the entire set. other in-flight aliases are still owned by
644    // live worker iterations and clearing them would allow duplicate signs.
645    let mut guard = match set.lock() {
646        Ok(g) => g,
647        Err(p) => p.into_inner(),
648    };
649    guard.remove(alias);
650}
651
652pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
653    let mut ctx = HostKeyResetCtx {
654        ui: &mut app.ui,
655        status: &mut app.status_center,
656        screen: &mut app.screen,
657        demo_mode: app.demo_mode,
658    };
659    let Screen::ConfirmHostKeyReset {
660        alias,
661        hostname,
662        known_hosts_path,
663        askpass,
664    } = &*ctx.screen
665    else {
666        return;
667    };
668    let alias = alias.clone();
669    let hostname = hostname.clone();
670    let known_hosts_path = known_hosts_path.clone();
671    let askpass = askpass.clone();
672    // Host key reset wipes the host's known_hosts entry. uniform y/n/Esc
673    // contract via the central router so stray keys cannot trigger it.
674    match route_confirm_key(key) {
675        ConfirmAction::Yes => {
676            let output = std::process::Command::new("ssh-keygen")
677                .arg("-R")
678                .arg(&hostname)
679                .arg("-f")
680                .arg(&known_hosts_path)
681                .output();
682
683            match output {
684                Ok(result) if result.status.success() => {
685                    ctx.notify(crate::messages::removed_host_key(&hostname));
686                    if ctx.demo_mode {
687                        ctx.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
688                    } else {
689                        ctx.ui.queue_connect(alias, askpass);
690                    }
691                }
692                Ok(result) => {
693                    let stderr = String::from_utf8_lossy(&result.stderr);
694                    ctx.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
695                }
696                Err(e) => {
697                    ctx.notify_error(crate::messages::ssh_keygen_failed(&e));
698                }
699            }
700            ctx.set_screen(Screen::HostList);
701        }
702        ConfirmAction::No => {
703            ctx.set_screen(Screen::HostList);
704        }
705        ConfirmAction::Ignored => {}
706    }
707}
708
709/// A confirmed container action plus its target(s). Single-container
710/// confirms carry one target; stack and host-wide confirms carry many.
711struct ContainerConfirm {
712    alias: String,
713    targets: Vec<(String, String)>,
714    action: crate::containers::ContainerAction,
715}
716
717/// Shared y/n/Esc routing for every container action confirm. Yes
718/// queues the action for each target then drops to the host list; No
719/// and Esc drop without side effects; anything else is ignored.
720fn apply_container_confirm(
721    ctx: &mut ContainerConfirmCtx,
722    key: KeyEvent,
723    confirm: ContainerConfirm,
724) {
725    match route_confirm_key(key) {
726        ConfirmAction::Yes => {
727            for (container_id, container_name) in confirm.targets {
728                queue_container_action(
729                    ctx,
730                    confirm.alias.clone(),
731                    container_id,
732                    container_name,
733                    confirm.action,
734                );
735            }
736            ctx.set_screen(Screen::HostList);
737        }
738        ConfirmAction::No => {
739            ctx.set_screen(Screen::HostList);
740        }
741        ConfirmAction::Ignored => {}
742    }
743}
744
745/// Borrow the disjoint App fields every container-action confirm needs.
746fn container_confirm_ctx(app: &mut App) -> ContainerConfirmCtx<'_> {
747    ContainerConfirmCtx {
748        container_state: &mut app.container_state,
749        hosts: &app.hosts_state,
750        screen: &mut app.screen,
751    }
752}
753
754/// Confirm handler for `K` (kick = restart): restart a single container.
755pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
756    let mut ctx = container_confirm_ctx(app);
757    let Screen::ConfirmContainerRestart {
758        alias,
759        container_id,
760        container_name,
761        ..
762    } = &*ctx.screen
763    else {
764        return;
765    };
766    let confirm = ContainerConfirm {
767        alias: alias.clone(),
768        targets: vec![(container_id.clone(), container_name.clone())],
769        action: crate::containers::ContainerAction::Restart,
770    };
771    apply_container_confirm(&mut ctx, key, confirm);
772}
773
774/// Confirm handler for `S` (stop): stop a single container.
775pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
776    let mut ctx = container_confirm_ctx(app);
777    let Screen::ConfirmContainerStop {
778        alias,
779        container_id,
780        container_name,
781        ..
782    } = &*ctx.screen
783    else {
784        return;
785    };
786    let confirm = ContainerConfirm {
787        alias: alias.clone(),
788        targets: vec![(container_id.clone(), container_name.clone())],
789        action: crate::containers::ContainerAction::Stop,
790    };
791    apply_container_confirm(&mut ctx, key, confirm);
792}
793
794/// Confirm handler for `Ctrl-K` (stack kick): restart every member of a
795/// compose stack. The drain queue processes one request per tick, so the
796/// restarts run sequentially.
797pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
798    bulk_confirm_key(
799        app,
800        key,
801        Screen::ConfirmStackRestart,
802        crate::containers::ContainerAction::Restart,
803    );
804}
805
806/// Confirm handler for `K` on a host-divider row: restart every running
807/// container on the host, ignoring compose-project boundaries.
808pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
809    bulk_confirm_key(
810        app,
811        key,
812        Screen::ConfirmHostRestartAll,
813        crate::containers::ContainerAction::Restart,
814    );
815}
816
817/// Confirm handler for `S` on a host-divider row: stop every running
818/// container on the host.
819pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
820    bulk_confirm_key(
821        app,
822        key,
823        Screen::ConfirmHostStopAll,
824        crate::containers::ContainerAction::Stop,
825    );
826}
827
828/// Shared dispatcher for the three bulk container confirm dialogs.
829/// `expected_screen` guards against firing the wrong action if the
830/// screen has drifted from `pending_bulk_confirm`. An ignored key
831/// leaves the payload in place so the dialog stays open; yes/no take
832/// the payload because `apply_container_confirm` transitions away.
833fn bulk_confirm_key(
834    app: &mut App,
835    key: KeyEvent,
836    expected_screen: Screen,
837    action: crate::containers::ContainerAction,
838) {
839    if app.screen != expected_screen {
840        return;
841    }
842    if route_confirm_key(key) == ConfirmAction::Ignored {
843        return;
844    }
845    let Some(payload) = app.containers_overview.take_pending_bulk_confirm() else {
846        // State pruned (host removed via reload_hosts while the dialog was
847        // open). Drop to HostList so the user is not stuck on an empty
848        // confirm screen with a non-functional Esc.
849        app.set_screen(Screen::HostList);
850        return;
851    };
852    let targets: Vec<(String, String)> = payload
853        .members
854        .iter()
855        .map(|m| (m.container_id.clone(), m.container_name.clone()))
856        .collect();
857    let confirm = ContainerConfirm {
858        alias: payload.alias,
859        targets,
860        action,
861    };
862    let mut ctx = container_confirm_ctx(app);
863    apply_container_confirm(&mut ctx, key, confirm);
864}
865
866fn queue_container_action(
867    ctx: &mut ContainerConfirmCtx,
868    alias: String,
869    container_id: String,
870    container_name: String,
871    action: crate::containers::ContainerAction,
872) {
873    let Some(entry) = ctx.container_state.cache_entry(&alias) else {
874        log::debug!(
875            "[purple] container_action: queue aborted, no cache for alias={}",
876            alias
877        );
878        return;
879    };
880    let runtime = entry.runtime;
881    let askpass = ctx
882        .hosts
883        .list()
884        .iter()
885        .find(|h| h.alias == alias)
886        .and_then(|h| h.askpass.clone());
887    log::info!(
888        "[purple] container_action queued: alias={} id={} action={:?}",
889        alias,
890        container_id,
891        action
892    );
893    ctx.container_state
894        .queue_action(crate::app::ContainerActionRequest {
895            alias,
896            askpass,
897            runtime,
898            container_id,
899            container_name,
900            action,
901        });
902}
903
904/// Confirm for the `p` push action from the Keys tab. Stakes test:
905/// pushing modifies remote `authorized_keys`, so the footer uses
906/// action verbs (`push` / `keep`) and we only accept y/n/Esc.
907pub(super) fn handle_key_push_key(
908    app: &mut App,
909    key: KeyEvent,
910    events_tx: &mpsc::Sender<AppEvent>,
911) {
912    let effects = {
913        let mut ctx = KeyPushConfirmCtx {
914            keys: &mut app.keys,
915            screen: &mut app.screen,
916            effects: Effects::default(),
917        };
918        match route_confirm_key(key) {
919            ConfirmAction::Yes => {
920                let key_index = match &*ctx.screen {
921                    Screen::ConfirmKeyPush { key_index } => *key_index,
922                    _ => return,
923                };
924                let aliases = std::mem::take(&mut ctx.keys.push_mut().committed);
925                ctx.set_screen(Screen::HostList);
926                // The push worker is the terminal whole-App op (reads the key
927                // list and config path, spawns a thread). it runs deferred
928                // after the screen transition. its first observable action is
929                // the guard/progress toast, with no intervening toast.
930                let tx = events_tx.clone();
931                ctx.defer(move |app| start_key_push(app, key_index, aliases, &tx));
932            }
933            ConfirmAction::No => {
934                // Return to the picker with the selection still intact so the
935                // user can refine it.
936                let key_index = match &*ctx.screen {
937                    Screen::ConfirmKeyPush { key_index } => *key_index,
938                    _ => return,
939                };
940                ctx.keys.push_mut().committed.clear();
941                ctx.set_screen(Screen::KeyPushPicker { key_index });
942            }
943            ConfirmAction::Ignored => {}
944        }
945        ctx.effects
946    };
947    effects.apply(app);
948}
949
950/// Confirm dialog for `Screen::ConfirmRunSnippet`. On Yes, run the chosen
951/// snippet against the committed hosts (this sets the next screen itself via
952/// `run_flow_snippet`). On No/Esc, return to the host picker with the selection
953/// intact. y/n/Esc are the only effective inputs (`route_confirm_key`).
954pub(super) fn handle_run_snippet_confirm_key(
955    app: &mut App,
956    key: KeyEvent,
957    events_tx: &mpsc::Sender<AppEvent>,
958) {
959    match route_confirm_key(key) {
960        ConfirmAction::Yes => {
961            super::snippet::run_flow_snippet(app, events_tx);
962        }
963        ConfirmAction::No => {
964            app.set_screen(Screen::SnippetHostPicker);
965        }
966        ConfirmAction::Ignored => {}
967    }
968}
969
970/// Spawn the background push worker. Reads the pubkey from disk on the
971/// main thread (cheap) so we surface an early error toast before
972/// committing to the run. On read failure we abort and stay on
973/// HostList. Refuses to start a second push while a first is still in
974/// flight (`expected_count > 0`); the user must press Esc to cancel
975/// before triggering another run.
976fn start_key_push(
977    app: &mut App,
978    key_index: usize,
979    aliases: Vec<String>,
980    events_tx: &mpsc::Sender<AppEvent>,
981) {
982    // Refuse second push while a previous run still has live state OR a
983    // worker handle that has not been observed to finish. Belt-and-braces:
984    // expected_count protects the in-flight branch, worker.is_finished()
985    // protects the post-cancel branch where the worker is still draining
986    // but its results no longer count toward any expected total.
987    if app.keys.push().expected_count > 0
988        || app
989            .keys
990            .push()
991            .worker
992            .as_ref()
993            .is_some_and(|h| !h.is_finished())
994    {
995        log::debug!(
996            "[purple] key_push: rejected second push, run already in progress ({} of {})",
997            app.keys.push().results.len(),
998            app.keys.push().expected_count
999        );
1000        app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
1001        return;
1002    }
1003    if aliases.is_empty() {
1004        log::debug!("[purple] key_push: rejected, no aliases committed");
1005        app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
1006        return;
1007    }
1008    let Some(key_info) = app.keys.list().get(key_index).cloned() else {
1009        return;
1010    };
1011    if key_info.is_certificate {
1012        app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
1013        return;
1014    }
1015    let pub_path = crate::key_push::pubkey_path_for(app.env.paths(), &key_info.display_path);
1016    let raw = match crate::key_push::read_pubkey_file(&pub_path) {
1017        Ok(s) => s,
1018        Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
1019            log::warn!(
1020                "[config] key_push: pubkey too large path={} bytes={}",
1021                pub_path.display(),
1022                n
1023            );
1024            app.notify_error(crate::messages::key_push_pubkey_too_large(
1025                &key_info.name,
1026                n,
1027            ));
1028            return;
1029        }
1030        Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
1031            log::warn!(
1032                "[config] key_push: pubkey not a regular file path={}",
1033                pub_path.display()
1034            );
1035            app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
1036            return;
1037        }
1038        Err(_) => {
1039            // Other validation variants are unreachable here (read_pubkey_file
1040            // only returns TooLarge / NotARegularFile / IO collapsed into
1041            // NotARegularFile). Defensive fallthrough.
1042            app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
1043            return;
1044        }
1045    };
1046    let pubkey = match crate::key_push::validate_pubkey(&raw) {
1047        Ok(s) => s,
1048        Err(err) => {
1049            let detail = match &err {
1050                crate::key_push::PubkeyValidationError::Empty => "file is empty",
1051                crate::key_push::PubkeyValidationError::MultiLine => {
1052                    "must be a single line; multi-line input is rejected"
1053                }
1054                crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
1055                    "key algorithm not allowed for static push"
1056                }
1057                crate::key_push::PubkeyValidationError::MalformedBase64 => {
1058                    "base64 key body did not parse"
1059                }
1060                _ => "unexpected format",
1061            };
1062            log::warn!(
1063                "[config] key_push: invalid pubkey path={} err={:?}",
1064                pub_path.display(),
1065                err
1066            );
1067            app.notify_error(crate::messages::key_push_invalid_pubkey(
1068                &key_info.name,
1069                detail,
1070            ));
1071            return;
1072        }
1073    };
1074
1075    // Reset accumulators and start a new run.
1076    let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
1077
1078    app.notify_progress(crate::messages::key_push_in_progress(
1079        &key_info.name,
1080        aliases.len(),
1081    ));
1082
1083    let config_path = app.hosts_state.ssh_config().path.clone();
1084    let tx = events_tx.clone();
1085    let pubkey_payload = pubkey;
1086    let handle = std::thread::Builder::new()
1087        .name("key-push".into())
1088        .spawn(move || {
1089            for alias in aliases {
1090                if cancel.load(Ordering::Relaxed) {
1091                    break;
1092                }
1093                let outcome =
1094                    crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
1095                let _ = tx.send(AppEvent::KeyPushResult {
1096                    run_id,
1097                    result: crate::key_push::KeyPushResult { alias, outcome },
1098                });
1099            }
1100        });
1101    match handle {
1102        Ok(h) => {
1103            app.keys.push_mut().worker = Some(h);
1104        }
1105        Err(e) => {
1106            log::error!("[purple] key_push: failed to spawn worker: {}", e);
1107            // Drop the progress toast through the status-center invariant
1108            // so the user does not see "Pushing..." stuck under the
1109            // failure message.
1110            app.status_center.clear_sticky_status();
1111            app.notify_error(crate::messages::key_push_thread_spawn_failed());
1112            app.keys.push_mut().clear_inflight_state();
1113        }
1114    }
1115}
1116
1117#[cfg(test)]
1118mod key_push_confirm_tests {
1119    //! Coverage for the gate functions wrapping the push-worker spawn.
1120    //! Every test exercises a guard path (already-running, missing pubkey,
1121    //! certificate key, empty selection, return-to-picker) and asserts the
1122    //! observable state. The happy-spawn path is intentionally not unit
1123    //! tested here because it forks an ssh subprocess; that path is
1124    //! covered by the event-loop tests against the run-completion flow.
1125    use super::*;
1126    use crate::ssh_config::model::SshConfigFile;
1127    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1128
1129    fn make_app() -> (App, std::path::PathBuf) {
1130        let scratch = tempfile::tempdir().expect("tempdir").keep();
1131        let config = SshConfigFile {
1132            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
1133            path: scratch.join("test_config"),
1134            crlf: false,
1135            bom: false,
1136        };
1137        let mut app = App::new(config);
1138        // Seed a non-cert key whose .pub file lives in the scratch dir so
1139        // `read_pubkey_file` succeeds via the absolute display path.
1140        let pub_path = scratch.join("id_test.pub");
1141        std::fs::write(
1142            &pub_path,
1143            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
1144        )
1145        .unwrap();
1146        app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1147            name: "id_test".into(),
1148            display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
1149            key_type: "ED25519".into(),
1150            bits: "256".into(),
1151            fingerprint: String::new(),
1152            comment: "test@host".into(),
1153            linked_hosts: vec![],
1154            bishop_art: String::new(),
1155            strength_score: 95,
1156            encrypted: false,
1157            agent_loaded: false,
1158            is_certificate: false,
1159            mtime_ts: None,
1160        });
1161        (app, scratch)
1162    }
1163
1164    fn k(code: KeyCode) -> KeyEvent {
1165        KeyEvent::new(code, KeyModifiers::NONE)
1166    }
1167
1168    #[test]
1169    fn n_returns_to_picker_with_key_index_preserved() {
1170        let (mut app, _scratch) = make_app();
1171        app.keys.push_mut().committed = vec!["h1".into()];
1172        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1173        let (tx, _rx) = mpsc::channel();
1174        handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
1175        match app.screen {
1176            Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
1177            ref other => panic!("expected KeyPushPicker, got {:?}", other),
1178        }
1179        assert!(
1180            app.keys.push().committed.is_empty(),
1181            "n should drop the frozen selection"
1182        );
1183    }
1184
1185    #[test]
1186    fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
1187        let (mut app, _scratch) = make_app();
1188        app.keys.push_mut().committed = vec!["h1".into()];
1189        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1190        let (tx, _rx) = mpsc::channel();
1191        handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
1192        assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
1193    }
1194
1195    #[test]
1196    fn start_rejects_when_a_previous_run_is_still_in_flight() {
1197        let (mut app, _scratch) = make_app();
1198        app.keys.push_mut().expected_count = 2;
1199        app.keys
1200            .push_mut()
1201            .results
1202            .push(crate::key_push::KeyPushResult {
1203                alias: "h1".into(),
1204                outcome: crate::key_push::KeyPushOutcome::Appended,
1205            });
1206        let (tx, _rx) = mpsc::channel();
1207        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1208        assert_eq!(
1209            app.keys.push().expected_count,
1210            2,
1211            "guard must not reset in-flight state"
1212        );
1213        let toast = app.status_center.toast().expect("toast set");
1214        assert!(
1215            toast.text.contains("already running"),
1216            "expected 'already running' warning, got: {}",
1217            toast.text
1218        );
1219    }
1220
1221    #[test]
1222    fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1223        let (mut app, _scratch) = make_app();
1224        let (tx, _rx) = mpsc::channel();
1225        start_key_push(&mut app, 0, Vec::new(), &tx);
1226        assert_eq!(app.keys.push().expected_count, 0);
1227        assert!(app.keys.push().worker.is_none());
1228        let toast = app.status_center.toast().expect("toast set");
1229        assert!(toast.is_error());
1230    }
1231
1232    #[test]
1233    fn start_rejects_certificate_key() {
1234        let (mut app, _scratch) = make_app();
1235        app.keys.list_mut()[0].is_certificate = true;
1236        let (tx, _rx) = mpsc::channel();
1237        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1238        assert_eq!(app.keys.push().expected_count, 0);
1239        assert!(app.keys.push().worker.is_none());
1240        let toast = app.status_center.toast().expect("toast set");
1241        assert!(toast.is_error());
1242        assert!(toast.text.contains("Certificates"));
1243    }
1244
1245    #[test]
1246    fn start_rejects_missing_pubkey_file() {
1247        let (mut app, _scratch) = make_app();
1248        app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1249        let (tx, _rx) = mpsc::channel();
1250        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1251        assert_eq!(app.keys.push().expected_count, 0);
1252        let toast = app.status_center.toast().expect("toast set");
1253        assert!(toast.is_error());
1254    }
1255
1256    #[test]
1257    fn start_rejects_invalid_pubkey_content() {
1258        let (mut app, scratch) = make_app();
1259        // Multi-line pubkey: the canonical command-injection PoC. Must be
1260        // rejected without spawning the worker.
1261        let pub_path = scratch.join("id_bad.pub");
1262        std::fs::write(
1263            &pub_path,
1264            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1265        )
1266        .unwrap();
1267        app.keys.list_mut()[0].display_path =
1268            pub_path.with_extension("").to_string_lossy().into_owned();
1269        app.keys.list_mut()[0].name = "id_bad".into();
1270        let (tx, _rx) = mpsc::channel();
1271        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1272        assert_eq!(app.keys.push().expected_count, 0);
1273        assert!(app.keys.push().worker.is_none());
1274        let toast = app.status_center.toast().expect("toast set");
1275        assert!(toast.is_error());
1276        assert!(toast.text.contains("validation"));
1277    }
1278
1279    #[test]
1280    fn run_snippet_confirm_no_returns_to_picker() {
1281        let scratch = tempfile::tempdir().expect("tempdir").keep();
1282        let config = SshConfigFile {
1283            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
1284            path: scratch.join("cfg"),
1285            crlf: false,
1286            bom: false,
1287        };
1288        let mut app = App::new(config);
1289        app.set_screen(Screen::ConfirmRunSnippet);
1290        let (tx, _rx) = mpsc::channel();
1291        handle_run_snippet_confirm_key(
1292            &mut app,
1293            KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
1294            &tx,
1295        );
1296        assert!(matches!(app.screen, Screen::SnippetHostPicker));
1297    }
1298
1299    #[test]
1300    fn run_snippet_confirm_yes_with_params_opens_param_form() {
1301        let scratch = tempfile::tempdir().expect("tempdir").keep();
1302        let config = SshConfigFile {
1303            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
1304            path: scratch.join("cfg"),
1305            crlf: false,
1306            bom: false,
1307        };
1308        let mut app = App::new(config);
1309        // A parametrised snippet: Yes prompts for params rather than spawning ssh.
1310        app.snippets.set_flow_snippet(Some(crate::snippet::Snippet {
1311            name: "deploy".into(),
1312            command: "echo {{msg}}".into(),
1313            description: String::new(),
1314        }));
1315        app.snippets.set_flow_targets(vec!["h1".into()]);
1316        app.set_screen(Screen::ConfirmRunSnippet);
1317        let (tx, _rx) = mpsc::channel();
1318        handle_run_snippet_confirm_key(
1319            &mut app,
1320            KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
1321            &tx,
1322        );
1323        assert!(matches!(app.screen, Screen::SnippetParamForm));
1324        assert!(app.snippets.form_return_to_tab());
1325    }
1326}