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