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