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/// Spawn the background push worker. Reads the pubkey from disk on the
951/// main thread (cheap) so we surface an early error toast before
952/// committing to the run. On read failure we abort and stay on
953/// HostList. Refuses to start a second push while a first is still in
954/// flight (`expected_count > 0`); the user must press Esc to cancel
955/// before triggering another run.
956fn start_key_push(
957    app: &mut App,
958    key_index: usize,
959    aliases: Vec<String>,
960    events_tx: &mpsc::Sender<AppEvent>,
961) {
962    // Refuse second push while a previous run still has live state OR a
963    // worker handle that has not been observed to finish. Belt-and-braces:
964    // expected_count protects the in-flight branch, worker.is_finished()
965    // protects the post-cancel branch where the worker is still draining
966    // but its results no longer count toward any expected total.
967    if app.keys.push().expected_count > 0
968        || app
969            .keys
970            .push()
971            .worker
972            .as_ref()
973            .is_some_and(|h| !h.is_finished())
974    {
975        log::debug!(
976            "[purple] key_push: rejected second push, run already in progress ({} of {})",
977            app.keys.push().results.len(),
978            app.keys.push().expected_count
979        );
980        app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
981        return;
982    }
983    if aliases.is_empty() {
984        log::debug!("[purple] key_push: rejected, no aliases committed");
985        app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
986        return;
987    }
988    let Some(key_info) = app.keys.list().get(key_index).cloned() else {
989        return;
990    };
991    if key_info.is_certificate {
992        app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
993        return;
994    }
995    let pub_path = crate::key_push::pubkey_path_for(app.env.paths(), &key_info.display_path);
996    let raw = match crate::key_push::read_pubkey_file(&pub_path) {
997        Ok(s) => s,
998        Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
999            log::warn!(
1000                "[config] key_push: pubkey too large path={} bytes={}",
1001                pub_path.display(),
1002                n
1003            );
1004            app.notify_error(crate::messages::key_push_pubkey_too_large(
1005                &key_info.name,
1006                n,
1007            ));
1008            return;
1009        }
1010        Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
1011            log::warn!(
1012                "[config] key_push: pubkey not a regular file path={}",
1013                pub_path.display()
1014            );
1015            app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
1016            return;
1017        }
1018        Err(_) => {
1019            // Other validation variants are unreachable here (read_pubkey_file
1020            // only returns TooLarge / NotARegularFile / IO collapsed into
1021            // NotARegularFile). Defensive fallthrough.
1022            app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
1023            return;
1024        }
1025    };
1026    let pubkey = match crate::key_push::validate_pubkey(&raw) {
1027        Ok(s) => s,
1028        Err(err) => {
1029            let detail = match &err {
1030                crate::key_push::PubkeyValidationError::Empty => "file is empty",
1031                crate::key_push::PubkeyValidationError::MultiLine => {
1032                    "must be a single line; multi-line input is rejected"
1033                }
1034                crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
1035                    "key algorithm not allowed for static push"
1036                }
1037                crate::key_push::PubkeyValidationError::MalformedBase64 => {
1038                    "base64 key body did not parse"
1039                }
1040                _ => "unexpected format",
1041            };
1042            log::warn!(
1043                "[config] key_push: invalid pubkey path={} err={:?}",
1044                pub_path.display(),
1045                err
1046            );
1047            app.notify_error(crate::messages::key_push_invalid_pubkey(
1048                &key_info.name,
1049                detail,
1050            ));
1051            return;
1052        }
1053    };
1054
1055    // Reset accumulators and start a new run.
1056    let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
1057
1058    app.notify_progress(crate::messages::key_push_in_progress(
1059        &key_info.name,
1060        aliases.len(),
1061    ));
1062
1063    let config_path = app.hosts_state.ssh_config().path.clone();
1064    let tx = events_tx.clone();
1065    let pubkey_payload = pubkey;
1066    let handle = std::thread::Builder::new()
1067        .name("key-push".into())
1068        .spawn(move || {
1069            for alias in aliases {
1070                if cancel.load(Ordering::Relaxed) {
1071                    break;
1072                }
1073                let outcome =
1074                    crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
1075                let _ = tx.send(AppEvent::KeyPushResult {
1076                    run_id,
1077                    result: crate::key_push::KeyPushResult { alias, outcome },
1078                });
1079            }
1080        });
1081    match handle {
1082        Ok(h) => {
1083            app.keys.push_mut().worker = Some(h);
1084        }
1085        Err(e) => {
1086            log::error!("[purple] key_push: failed to spawn worker: {}", e);
1087            // Drop the progress toast through the status-center invariant
1088            // so the user does not see "Pushing..." stuck under the
1089            // failure message.
1090            app.status_center.clear_sticky_status();
1091            app.notify_error(crate::messages::key_push_thread_spawn_failed());
1092            app.keys.push_mut().clear_inflight_state();
1093        }
1094    }
1095}
1096
1097#[cfg(test)]
1098mod key_push_confirm_tests {
1099    //! Coverage for the gate functions wrapping the push-worker spawn.
1100    //! Every test exercises a guard path (already-running, missing pubkey,
1101    //! certificate key, empty selection, return-to-picker) and asserts the
1102    //! observable state. The happy-spawn path is intentionally not unit
1103    //! tested here because it forks an ssh subprocess; that path is
1104    //! covered by the event-loop tests against the run-completion flow.
1105    use super::*;
1106    use crate::ssh_config::model::SshConfigFile;
1107    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1108
1109    fn make_app() -> (App, std::path::PathBuf) {
1110        let scratch = tempfile::tempdir().expect("tempdir").keep();
1111        let config = SshConfigFile {
1112            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
1113            path: scratch.join("test_config"),
1114            crlf: false,
1115            bom: false,
1116        };
1117        let mut app = App::new(config);
1118        // Seed a non-cert key whose .pub file lives in the scratch dir so
1119        // `read_pubkey_file` succeeds via the absolute display path.
1120        let pub_path = scratch.join("id_test.pub");
1121        std::fs::write(
1122            &pub_path,
1123            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
1124        )
1125        .unwrap();
1126        app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1127            name: "id_test".into(),
1128            display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
1129            key_type: "ED25519".into(),
1130            bits: "256".into(),
1131            fingerprint: String::new(),
1132            comment: "test@host".into(),
1133            linked_hosts: vec![],
1134            bishop_art: String::new(),
1135            strength_score: 95,
1136            encrypted: false,
1137            agent_loaded: false,
1138            is_certificate: false,
1139            mtime_ts: None,
1140        });
1141        (app, scratch)
1142    }
1143
1144    fn k(code: KeyCode) -> KeyEvent {
1145        KeyEvent::new(code, KeyModifiers::NONE)
1146    }
1147
1148    #[test]
1149    fn n_returns_to_picker_with_key_index_preserved() {
1150        let (mut app, _scratch) = make_app();
1151        app.keys.push_mut().committed = vec!["h1".into()];
1152        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1153        let (tx, _rx) = mpsc::channel();
1154        handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
1155        match app.screen {
1156            Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
1157            ref other => panic!("expected KeyPushPicker, got {:?}", other),
1158        }
1159        assert!(
1160            app.keys.push().committed.is_empty(),
1161            "n should drop the frozen selection"
1162        );
1163    }
1164
1165    #[test]
1166    fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
1167        let (mut app, _scratch) = make_app();
1168        app.keys.push_mut().committed = vec!["h1".into()];
1169        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1170        let (tx, _rx) = mpsc::channel();
1171        handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
1172        assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
1173    }
1174
1175    #[test]
1176    fn start_rejects_when_a_previous_run_is_still_in_flight() {
1177        let (mut app, _scratch) = make_app();
1178        app.keys.push_mut().expected_count = 2;
1179        app.keys
1180            .push_mut()
1181            .results
1182            .push(crate::key_push::KeyPushResult {
1183                alias: "h1".into(),
1184                outcome: crate::key_push::KeyPushOutcome::Appended,
1185            });
1186        let (tx, _rx) = mpsc::channel();
1187        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1188        assert_eq!(
1189            app.keys.push().expected_count,
1190            2,
1191            "guard must not reset in-flight state"
1192        );
1193        let toast = app.status_center.toast().expect("toast set");
1194        assert!(
1195            toast.text.contains("already running"),
1196            "expected 'already running' warning, got: {}",
1197            toast.text
1198        );
1199    }
1200
1201    #[test]
1202    fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1203        let (mut app, _scratch) = make_app();
1204        let (tx, _rx) = mpsc::channel();
1205        start_key_push(&mut app, 0, Vec::new(), &tx);
1206        assert_eq!(app.keys.push().expected_count, 0);
1207        assert!(app.keys.push().worker.is_none());
1208        let toast = app.status_center.toast().expect("toast set");
1209        assert!(toast.is_error());
1210    }
1211
1212    #[test]
1213    fn start_rejects_certificate_key() {
1214        let (mut app, _scratch) = make_app();
1215        app.keys.list_mut()[0].is_certificate = true;
1216        let (tx, _rx) = mpsc::channel();
1217        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1218        assert_eq!(app.keys.push().expected_count, 0);
1219        assert!(app.keys.push().worker.is_none());
1220        let toast = app.status_center.toast().expect("toast set");
1221        assert!(toast.is_error());
1222        assert!(toast.text.contains("Certificates"));
1223    }
1224
1225    #[test]
1226    fn start_rejects_missing_pubkey_file() {
1227        let (mut app, _scratch) = make_app();
1228        app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1229        let (tx, _rx) = mpsc::channel();
1230        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1231        assert_eq!(app.keys.push().expected_count, 0);
1232        let toast = app.status_center.toast().expect("toast set");
1233        assert!(toast.is_error());
1234    }
1235
1236    #[test]
1237    fn start_rejects_invalid_pubkey_content() {
1238        let (mut app, scratch) = make_app();
1239        // Multi-line pubkey: the canonical command-injection PoC. Must be
1240        // rejected without spawning the worker.
1241        let pub_path = scratch.join("id_bad.pub");
1242        std::fs::write(
1243            &pub_path,
1244            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1245        )
1246        .unwrap();
1247        app.keys.list_mut()[0].display_path =
1248            pub_path.with_extension("").to_string_lossy().into_owned();
1249        app.keys.list_mut()[0].name = "id_bad".into();
1250        let (tx, _rx) = mpsc::channel();
1251        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1252        assert_eq!(app.keys.push().expected_count, 0);
1253        assert!(app.keys.push().worker.is_none());
1254        let toast = app.status_center.toast().expect("toast set");
1255        assert!(toast.is_error());
1256        assert!(toast.text.contains("validation"));
1257    }
1258}