Skip to main content

purple_ssh/handler/
confirm.rs

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