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 crate::app::{App, Screen};
8use crate::event::AppEvent;
9
10/// Result of routing a confirm-dialog key event.
11///
12/// Confirm dialogs accept exactly three classes of keys:
13/// - `Yes`: y / Y
14/// - `No`: n / N / Esc
15/// - `Ignored`: anything else (must NOT change app state)
16///
17/// **Critical safety invariant**: a `_ =>` catch-all in a confirm handler
18/// that transitions screen state is forbidden. A misplaced keypress must not
19/// silently cancel a destructive operation. Use [`route_confirm_key`] in every
20/// confirm handler to enforce the contract.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ConfirmAction {
23    Yes,
24    No,
25    Ignored,
26}
27
28/// Single source of truth for confirm-dialog key routing.
29pub fn route_confirm_key(key: KeyEvent) -> ConfirmAction {
30    match key.code {
31        KeyCode::Char('y') | KeyCode::Char('Y') => ConfirmAction::Yes,
32        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => ConfirmAction::No,
33        _ => ConfirmAction::Ignored,
34    }
35}
36
37/// Run known_hosts import and set status. Used by both ConfirmImport and Welcome handlers.
38pub(super) fn execute_known_hosts_import(app: &mut App) {
39    let config_backup = app.hosts_state.ssh_config.clone();
40    match crate::import::import_from_known_hosts(
41        &mut app.hosts_state.ssh_config,
42        Some("known_hosts"),
43    ) {
44        Ok((imported, skipped, _, _)) => {
45            if imported > 0 {
46                if let Err(e) = app.hosts_state.ssh_config.write() {
47                    app.hosts_state.ssh_config = config_backup;
48                    app.notify_error(crate::messages::failed_to_save(&e));
49                    return;
50                }
51                app.reload_hosts();
52                app.notify(crate::messages::imported_hosts(imported, skipped));
53            } else {
54                app.notify(crate::messages::all_hosts_exist(skipped));
55            }
56            app.ui.known_hosts_count = 0;
57        }
58        Err(e) => {
59            app.notify_error(e);
60        }
61    }
62}
63
64pub(super) fn handle_import_key(app: &mut App, key: KeyEvent) {
65    match route_confirm_key(key) {
66        ConfirmAction::Yes => {
67            app.set_screen(Screen::HostList);
68            execute_known_hosts_import(app);
69        }
70        ConfirmAction::No => {
71            app.set_screen(Screen::HostList);
72        }
73        ConfirmAction::Ignored => {}
74    }
75}
76
77pub(super) fn handle_purge_stale_key(app: &mut App, key: KeyEvent) {
78    let Screen::ConfirmPurgeStale { provider: p, .. } = &app.screen else {
79        return;
80    };
81    let provider = p.clone();
82    let return_screen = if provider.is_some() {
83        Screen::Providers
84    } else {
85        Screen::HostList
86    };
87    match route_confirm_key(key) {
88        ConfirmAction::Yes => {
89            execute_purge_stale(app, provider.as_deref());
90            app.screen = return_screen;
91        }
92        ConfirmAction::No => {
93            app.screen = return_screen;
94        }
95        ConfirmAction::Ignored => {}
96    }
97}
98
99fn execute_purge_stale(app: &mut App, provider: Option<&str>) {
100    let stale = app.hosts_state.ssh_config.stale_hosts();
101    if stale.is_empty() {
102        return;
103    }
104    // Filter by provider if specified.
105    let targets: Vec<(String, u64)> = if let Some(prov) = provider {
106        stale
107            .into_iter()
108            .filter(|(alias, _)| {
109                app.hosts_state
110                    .ssh_config
111                    .host_entries()
112                    .iter()
113                    .any(|e| e.alias == *alias && e.provider.as_deref() == Some(prov))
114            })
115            .collect()
116    } else {
117        stale
118    };
119    if targets.is_empty() {
120        return;
121    }
122    let config_backup = app.hosts_state.ssh_config.clone();
123    let count = targets.len();
124    for (alias, _) in &targets {
125        app.hosts_state.ssh_config.delete_host(alias);
126    }
127    if let Err(e) = app.hosts_state.ssh_config.write() {
128        app.hosts_state.ssh_config = config_backup;
129        app.notify_error(crate::messages::failed_to_save(&e));
130        return;
131    }
132    // Kill active tunnels only after successful write (no rollback needed).
133    for (alias, _) in &targets {
134        if let Some(mut tunnel) = app.tunnels.active.remove(alias) {
135            let _ = tunnel.child.kill();
136            let _ = tunnel.child.wait();
137        }
138    }
139    app.hosts_state.undo_stack.clear();
140    app.update_last_modified();
141    app.reload_hosts();
142    let msg = if let Some(prov) = provider {
143        let display = crate::providers::provider_display_name(prov);
144        format!(
145            "Removed {} stale {} host{}.",
146            count,
147            display,
148            if count == 1 { "" } else { "s" }
149        )
150    } else {
151        format!(
152            "Removed {} stale host{}.",
153            count,
154            if count == 1 { "" } else { "s" }
155        )
156    };
157    app.notify(msg);
158}
159
160pub(super) fn handle_delete_key(app: &mut App, key: KeyEvent) {
161    let Screen::ConfirmDelete { alias } = &app.screen else {
162        return;
163    };
164    let alias = alias.clone();
165    // Use the central confirm-key router so the y/n/Esc contract is uniform
166    // across all confirm dialogs.
167    match route_confirm_key(key) {
168        ConfirmAction::Yes => {
169            let siblings = app.hosts_state.ssh_config.siblings_of(&alias);
170
171            if !siblings.is_empty() {
172                // Multi-alias block: strip only the selected token.
173                // `delete_host_undoable` refuses this case (returning
174                // None) because re-inserting the whole element via
175                // `insert_host_at` cannot reverse a token strip. We
176                // therefore skip the undo stack and surface the event
177                // via a dedicated toast that names the surviving
178                // siblings, so the user knows what did and did not
179                // change on disk.
180                app.hosts_state.ssh_config.delete_host(&alias);
181                if let Err(e) = app.hosts_state.ssh_config.write() {
182                    // Disk write failed: reload from disk to discard
183                    // the in-memory strip so view and storage match.
184                    app.notify_error(crate::messages::failed_to_save(&e));
185                    app.reload_hosts();
186                } else {
187                    if let Some(mut tunnel) = app.tunnels.active.remove(&alias) {
188                        let _ = tunnel.child.kill();
189                        let _ = tunnel.child.wait();
190                    }
191                    app.update_last_modified();
192                    app.reload_hosts();
193                    app.notify(crate::messages::siblings_stripped(&alias, siblings.len()));
194                }
195            } else if let Some((element, position)) =
196                app.hosts_state.ssh_config.delete_host_undoable(&alias)
197            {
198                if let Err(e) = app.hosts_state.ssh_config.write() {
199                    // Restore the element on write failure
200                    app.hosts_state.ssh_config.insert_host_at(element, position);
201                    app.notify_error(crate::messages::failed_to_save(&e));
202                } else {
203                    // Stop active tunnel for the deleted host
204                    if let Some(mut tunnel) = app.tunnels.active.remove(&alias) {
205                        let _ = tunnel.child.kill();
206                        let _ = tunnel.child.wait();
207                    }
208                    // Clean up cert file if it exists. NotFound is the
209                    // expected case for hosts that never had a cert. Other
210                    // errors are surfaced via the status bar (never via
211                    // eprintln, which would corrupt the ratatui screen).
212                    let mut cert_cleanup_warning: Option<String> = None;
213                    if !crate::demo_flag::is_demo() {
214                        if let Ok(cert_path) = crate::vault_ssh::cert_path_for(&alias) {
215                            match std::fs::remove_file(&cert_path) {
216                                Ok(()) => {}
217                                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
218                                Err(e) => {
219                                    cert_cleanup_warning =
220                                        Some(crate::messages::cert_cleanup_warning(
221                                            &cert_path.display(),
222                                            &e,
223                                        ));
224                                }
225                            }
226                        }
227                    }
228                    app.hosts_state
229                        .undo_stack
230                        .push(crate::app::DeletedHost { element, position });
231                    if app.hosts_state.undo_stack.len() > 50 {
232                        app.hosts_state.undo_stack.remove(0);
233                    }
234                    app.update_last_modified();
235                    app.reload_hosts();
236                    if let Some(warning) = cert_cleanup_warning {
237                        app.notify_error(warning);
238                    } else {
239                        app.notify(crate::messages::goodbye_host(&alias));
240                    }
241                }
242            } else {
243                app.notify_warning(crate::messages::host_not_found(&alias));
244            }
245            app.set_screen(Screen::HostList);
246        }
247        ConfirmAction::No => {
248            app.set_screen(Screen::HostList);
249        }
250        ConfirmAction::Ignored => {}
251    }
252}
253
254pub(super) fn handle_vault_sign_key(
255    app: &mut App,
256    key: KeyEvent,
257    events_tx: &mpsc::Sender<AppEvent>,
258) {
259    // Vault Sign is a destructive/material action: signing N certificates
260    // hits Vault, may take time and is hard to reverse. Stray keys must NOT
261    // cancel. use `route_confirm_key` so only y/Y/n/N/Esc are honored.
262    // History: an earlier `_ => app.screen = Screen::HostList` catch-all
263    // could be triggered by any keypress next to `y` (e.g. fat-fingered
264    // `t` or `u`), silently aborting a bulk sign.
265    match route_confirm_key(key) {
266        ConfirmAction::Yes => {
267            // Extract the precomputed signable list, then transition back to
268            // the host list and kick off the background signing loop.
269            let signable = if let Screen::ConfirmVaultSign { signable } = &app.screen {
270                signable.clone()
271            } else {
272                return;
273            };
274            app.set_screen(Screen::HostList);
275            start_vault_bulk_sign(app, signable, events_tx);
276        }
277        ConfirmAction::No => {
278            app.set_screen(Screen::HostList);
279        }
280        ConfirmAction::Ignored => {}
281    }
282}
283
284/// Start the background vault bulk sign loop with fast-fail, progress, TOCTOU
285/// coordination and cancellation. Stores the JoinHandle on App for clean exit.
286fn start_vault_bulk_sign(
287    app: &mut App,
288    signable: Vec<crate::vault_ssh::VaultSignTarget>,
289    events_tx: &mpsc::Sender<AppEvent>,
290) {
291    let total = signable.len();
292    if total == 0 {
293        return;
294    }
295    app.notify_progress(crate::messages::vault_signing_progress(
296        crate::animation::SPINNER_FRAMES[0],
297        0,
298        total,
299        "",
300    ));
301
302    let cancel = Arc::new(AtomicBool::new(false));
303    app.vault.signing_cancel = Some(cancel.clone());
304
305    let in_flight = app.vault.sign_in_flight.clone();
306    let tx = events_tx.clone();
307    let spawn_result = std::thread::Builder::new()
308        .name("vault-bulk-sign".into())
309        .spawn(move || {
310            let mut signed = 0u32;
311            let mut failed = 0u32;
312            let mut skipped = 0u32;
313            let mut consecutive_failures = 0usize;
314            let mut first_error: Option<String> = None;
315            let mut aborted_message: Option<String> = None;
316
317            for (idx, target) in signable.iter().enumerate() {
318                let crate::vault_ssh::VaultSignTarget {
319                    alias,
320                    role,
321                    certificate_file: cert_file,
322                    pubkey,
323                    vault_addr,
324                } = target;
325                if cancel.load(Ordering::Relaxed) {
326                    break;
327                }
328                let done = idx + 1;
329
330                // TOCTOU: skip host if another thread already has it in-flight.
331                // Otherwise mark it in-flight for the duration of this iteration.
332                {
333                    // If the mutex is poisoned a worker thread panicked while holding
334                    // the lock. Recover the inner value without clearing. clearing
335                    // the whole set would make every in-flight alias simultaneously
336                    // eligible for re-signing, risking duplicate cert writes.
337                    let mut set = match in_flight.lock() {
338                        Ok(g) => g,
339                        Err(p) => p.into_inner(),
340                    };
341                    if !set.insert(alias.clone()) {
342                        skipped += 1;
343                        let _ = tx.send(AppEvent::VaultSignProgress {
344                            alias: alias.clone(),
345                            done,
346                            total,
347                        });
348                        continue;
349                    }
350                }
351
352                let _ = tx.send(AppEvent::VaultSignProgress {
353                    alias: alias.clone(),
354                    done,
355                    total,
356                });
357
358                let cert_path = match crate::vault_ssh::resolve_cert_path(alias, cert_file) {
359                    Ok(p) => p,
360                    Err(e) => {
361                        failed += 1;
362                        consecutive_failures += 1;
363                        let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
364                        if first_error.is_none() {
365                            first_error = Some(scrubbed);
366                        }
367                        remove_in_flight(&in_flight, alias);
368                        if consecutive_failures >= 3 {
369                            aborted_message = Some(crate::messages::vault_signing_aborted(
370                                failed,
371                                first_error.as_deref(),
372                            ));
373                            break;
374                        }
375                        continue;
376                    }
377                };
378                let status = crate::vault_ssh::check_cert_validity(&cert_path);
379                if !crate::vault_ssh::needs_renewal(&status) {
380                    skipped += 1;
381                    consecutive_failures = 0;
382                    remove_in_flight(&in_flight, alias);
383                    continue;
384                }
385
386                let sign_result =
387                    crate::vault_ssh::sign_certificate(role, pubkey, alias, vault_addr.as_deref());
388                // Always clean up in_flight for this alias before handling the
389                // result. Using a single cleanup point (rather than per-arm)
390                // prevents orphaned aliases when new control flow is added.
391                remove_in_flight(&in_flight, alias);
392                match sign_result {
393                    Ok(_) => {
394                        let _ = tx.send(AppEvent::VaultSignResult {
395                            alias: alias.clone(),
396                            certificate_file: cert_file.clone(),
397                            success: true,
398                            message: String::new(),
399                        });
400                        signed += 1;
401                        consecutive_failures = 0;
402                    }
403                    Err(e) => {
404                        let raw = e.to_string();
405                        let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
406                        if first_error.is_none() {
407                            first_error = Some(scrubbed.clone());
408                        }
409                        let _ = tx.send(AppEvent::VaultSignResult {
410                            alias: alias.clone(),
411                            certificate_file: cert_file.clone(),
412                            success: false,
413                            message: scrubbed,
414                        });
415                        failed += 1;
416                        consecutive_failures += 1;
417                        if consecutive_failures >= 3 {
418                            aborted_message = Some(crate::messages::vault_signing_aborted(
419                                failed,
420                                first_error.as_deref(),
421                            ));
422                            break;
423                        }
424                    }
425                }
426            }
427
428            let cancelled = cancel.load(Ordering::Relaxed);
429            let _ = tx.send(AppEvent::VaultSignAllDone {
430                signed,
431                failed,
432                skipped,
433                cancelled,
434                aborted_message,
435                first_error,
436            });
437        });
438    match spawn_result {
439        Ok(handle) => {
440            log::info!("[purple] vault sign thread: spawned");
441            app.vault.sign_thread = Some(handle);
442        }
443        Err(e) => {
444            // Spawn failed (e.g. OS thread limit). Clear the cancel flag and
445            // surface the error. otherwise the status bar is stuck at
446            // "Signing 0/N" with no way for the user to recover.
447            log::warn!("[purple] vault sign thread: spawn failed: {}", e);
448            let _ = app.vault.finalize_signing_run();
449            app.notify_error(crate::messages::vault_spawn_failed(&e));
450        }
451    }
452}
453
454pub(super) fn remove_in_flight(
455    set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
456    alias: &str,
457) {
458    // On mutex poison, recover the inner value and remove only the target alias.
459    // Do NOT clear the entire set. other in-flight aliases are still owned by
460    // live worker iterations and clearing them would allow duplicate signs.
461    let mut guard = match set.lock() {
462        Ok(g) => g,
463        Err(p) => p.into_inner(),
464    };
465    guard.remove(alias);
466}
467
468pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
469    let Screen::ConfirmHostKeyReset {
470        alias,
471        hostname,
472        known_hosts_path,
473        askpass,
474    } = &app.screen
475    else {
476        return;
477    };
478    let alias = alias.clone();
479    let hostname = hostname.clone();
480    let known_hosts_path = known_hosts_path.clone();
481    let askpass = askpass.clone();
482    // Host key reset wipes the host's known_hosts entry. uniform y/n/Esc
483    // contract via the central router so stray keys cannot trigger it.
484    match route_confirm_key(key) {
485        ConfirmAction::Yes => {
486            let output = std::process::Command::new("ssh-keygen")
487                .arg("-R")
488                .arg(&hostname)
489                .arg("-f")
490                .arg(&known_hosts_path)
491                .output();
492
493            match output {
494                Ok(result) if result.status.success() => {
495                    app.notify(crate::messages::removed_host_key(&hostname));
496                    if app.demo_mode {
497                        app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
498                    } else {
499                        app.ui.queue_connect(alias, askpass);
500                    }
501                }
502                Ok(result) => {
503                    let stderr = String::from_utf8_lossy(&result.stderr);
504                    app.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
505                }
506                Err(e) => {
507                    app.notify_error(crate::messages::ssh_keygen_failed(&e));
508                }
509            }
510            app.set_screen(Screen::HostList);
511        }
512        ConfirmAction::No => {
513            app.set_screen(Screen::HostList);
514        }
515        ConfirmAction::Ignored => {}
516    }
517}
518
519/// Confirm handler for `K` (kick = restart). On Yes, queues a
520/// `ContainerActionKind::Restart` request; the main loop picks it
521/// up, fires the SSH command, and emits a result event. On No or
522/// Esc, the screen drops without side effects.
523pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
524    let Screen::ConfirmContainerRestart {
525        alias,
526        container_id,
527        container_name,
528        ..
529    } = &app.screen
530    else {
531        return;
532    };
533    let alias = alias.clone();
534    let container_id = container_id.clone();
535    let container_name = container_name.clone();
536    match route_confirm_key(key) {
537        ConfirmAction::Yes => {
538            queue_container_action(
539                app,
540                alias,
541                container_id,
542                container_name,
543                crate::containers::ContainerAction::Restart,
544            );
545            app.set_screen(Screen::HostList);
546        }
547        ConfirmAction::No => {
548            app.set_screen(Screen::HostList);
549        }
550        ConfirmAction::Ignored => {}
551    }
552}
553
554/// Confirm handler for `S` (stop). Same shape as restart; the action
555/// kind differs and so does the destructive wording in the dialog
556/// body.
557pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
558    let Screen::ConfirmContainerStop {
559        alias,
560        container_id,
561        container_name,
562        ..
563    } = &app.screen
564    else {
565        return;
566    };
567    let alias = alias.clone();
568    let container_id = container_id.clone();
569    let container_name = container_name.clone();
570    match route_confirm_key(key) {
571        ConfirmAction::Yes => {
572            queue_container_action(
573                app,
574                alias,
575                container_id,
576                container_name,
577                crate::containers::ContainerAction::Stop,
578            );
579            app.set_screen(Screen::HostList);
580        }
581        ConfirmAction::No => {
582            app.set_screen(Screen::HostList);
583        }
584        ConfirmAction::Ignored => {}
585    }
586}
587
588/// Confirm handler for `Ctrl-K` (stack kick). Iterates the stored
589/// member list and queues a Restart for each through the same drain
590/// mechanism that powers single-container restart. The drain
591/// processes one request per tick, giving a sequential cadence.
592pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
593    let Screen::ConfirmStackRestart { alias, members, .. } = &app.screen else {
594        return;
595    };
596    let alias = alias.clone();
597    let members = members.clone();
598    match route_confirm_key(key) {
599        ConfirmAction::Yes => {
600            for m in members {
601                queue_container_action(
602                    app,
603                    alias.clone(),
604                    m.container_id,
605                    m.container_name,
606                    crate::containers::ContainerAction::Restart,
607                );
608            }
609            app.set_screen(Screen::HostList);
610        }
611        ConfirmAction::No => {
612            app.set_screen(Screen::HostList);
613        }
614        ConfirmAction::Ignored => {}
615    }
616}
617
618/// Confirm handler for `K` on a host-divider row in the containers
619/// overview. Iterates every running container of the host and queues
620/// a Restart, regardless of compose project. Mirrors the stack-restart
621/// drain. one request per tick keeps remote SSH sane.
622pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
623    let Screen::ConfirmHostRestartAll { alias, members } = &app.screen else {
624        return;
625    };
626    let alias = alias.clone();
627    let members = members.clone();
628    match route_confirm_key(key) {
629        ConfirmAction::Yes => {
630            for m in members {
631                queue_container_action(
632                    app,
633                    alias.clone(),
634                    m.container_id,
635                    m.container_name,
636                    crate::containers::ContainerAction::Restart,
637                );
638            }
639            app.set_screen(Screen::HostList);
640        }
641        ConfirmAction::No => {
642            app.set_screen(Screen::HostList);
643        }
644        ConfirmAction::Ignored => {}
645    }
646}
647
648/// Confirm handler for `S` on a host-divider row. Stops every running
649/// container on the host. Same drain shape as host-restart.
650pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
651    let Screen::ConfirmHostStopAll { alias, members } = &app.screen else {
652        return;
653    };
654    let alias = alias.clone();
655    let members = members.clone();
656    match route_confirm_key(key) {
657        ConfirmAction::Yes => {
658            for m in members {
659                queue_container_action(
660                    app,
661                    alias.clone(),
662                    m.container_id,
663                    m.container_name,
664                    crate::containers::ContainerAction::Stop,
665                );
666            }
667            app.set_screen(Screen::HostList);
668        }
669        ConfirmAction::No => {
670            app.set_screen(Screen::HostList);
671        }
672        ConfirmAction::Ignored => {}
673    }
674}
675
676fn queue_container_action(
677    app: &mut App,
678    alias: String,
679    container_id: String,
680    container_name: String,
681    action: crate::containers::ContainerAction,
682) {
683    let Some(entry) = app.container_state.cache.get(&alias) else {
684        log::debug!(
685            "[purple] container_action: queue aborted, no cache for alias={}",
686            alias
687        );
688        return;
689    };
690    let runtime = entry.runtime;
691    let askpass = app
692        .hosts_state
693        .list
694        .iter()
695        .find(|h| h.alias == alias)
696        .and_then(|h| h.askpass.clone());
697    log::info!(
698        "[purple] container_action queued: alias={} id={} action={:?}",
699        alias,
700        container_id,
701        action
702    );
703    app.container_state
704        .queue_action(crate::app::ContainerActionRequest {
705            alias,
706            askpass,
707            runtime,
708            container_id,
709            container_name,
710            action,
711        });
712}
713
714/// Confirm for the `p` push action from the Keys tab. Stakes test:
715/// pushing modifies remote `authorized_keys`, so the footer uses
716/// action verbs (`push` / `keep`) and we only accept y/n/Esc.
717pub(super) fn handle_key_push_key(
718    app: &mut App,
719    key: KeyEvent,
720    events_tx: &mpsc::Sender<AppEvent>,
721) {
722    match route_confirm_key(key) {
723        ConfirmAction::Yes => {
724            let key_index = match &app.screen {
725                Screen::ConfirmKeyPush { key_index } => *key_index,
726                _ => return,
727            };
728            let aliases = std::mem::take(&mut app.keys.push.committed);
729            app.set_screen(Screen::HostList);
730            start_key_push(app, key_index, aliases, events_tx);
731        }
732        ConfirmAction::No => {
733            // Return to the picker with the selection still intact so the
734            // user can refine it.
735            let key_index = match &app.screen {
736                Screen::ConfirmKeyPush { key_index } => *key_index,
737                _ => return,
738            };
739            app.keys.push.committed.clear();
740            app.set_screen(Screen::KeyPushPicker { key_index });
741        }
742        ConfirmAction::Ignored => {}
743    }
744}
745
746/// Spawn the background push worker. Reads the pubkey from disk on the
747/// main thread (cheap) so we surface an early error toast before
748/// committing to the run. On read failure we abort and stay on
749/// HostList. Refuses to start a second push while a first is still in
750/// flight (`expected_count > 0`); the user must press Esc to cancel
751/// before triggering another run.
752fn start_key_push(
753    app: &mut App,
754    key_index: usize,
755    aliases: Vec<String>,
756    events_tx: &mpsc::Sender<AppEvent>,
757) {
758    // Refuse second push while a previous run still has live state OR a
759    // worker handle that has not been observed to finish. Belt-and-braces:
760    // expected_count protects the in-flight branch, worker.is_finished()
761    // protects the post-cancel branch where the worker is still draining
762    // but its results no longer count toward any expected total.
763    if app.keys.push.expected_count > 0
764        || app
765            .keys
766            .push
767            .worker
768            .as_ref()
769            .is_some_and(|h| !h.is_finished())
770    {
771        log::debug!(
772            "[purple] key_push: rejected second push, run already in progress ({} of {})",
773            app.keys.push.results.len(),
774            app.keys.push.expected_count
775        );
776        app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
777        return;
778    }
779    if aliases.is_empty() {
780        log::debug!("[purple] key_push: rejected, no aliases committed");
781        app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
782        return;
783    }
784    let Some(key_info) = app.keys.list.get(key_index).cloned() else {
785        return;
786    };
787    if key_info.is_certificate {
788        app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
789        return;
790    }
791    let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
792    let raw = match crate::key_push::read_pubkey_file(&pub_path) {
793        Ok(s) => s,
794        Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
795            log::warn!(
796                "[purple] key_push: pubkey too large path={} bytes={}",
797                pub_path.display(),
798                n
799            );
800            app.notify_error(crate::messages::key_push_pubkey_too_large(
801                &key_info.name,
802                n,
803            ));
804            return;
805        }
806        Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
807            log::warn!(
808                "[purple] key_push: pubkey not a regular file path={}",
809                pub_path.display()
810            );
811            app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
812            return;
813        }
814        Err(_) => {
815            // Other validation variants are unreachable here (read_pubkey_file
816            // only returns TooLarge / NotARegularFile / IO collapsed into
817            // NotARegularFile). Defensive fallthrough.
818            app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
819            return;
820        }
821    };
822    let pubkey = match crate::key_push::validate_pubkey(&raw) {
823        Ok(s) => s,
824        Err(err) => {
825            let detail = match &err {
826                crate::key_push::PubkeyValidationError::Empty => "file is empty",
827                crate::key_push::PubkeyValidationError::MultiLine => {
828                    "must be a single line; multi-line input is rejected"
829                }
830                crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
831                    "key algorithm not allowed for static push"
832                }
833                crate::key_push::PubkeyValidationError::MalformedBase64 => {
834                    "base64 key body did not parse"
835                }
836                _ => "unexpected format",
837            };
838            log::warn!(
839                "[purple] key_push: invalid pubkey path={} err={:?}",
840                pub_path.display(),
841                err
842            );
843            app.notify_error(crate::messages::key_push_invalid_pubkey(
844                &key_info.name,
845                detail,
846            ));
847            return;
848        }
849    };
850
851    // Reset accumulators and start a new run.
852    let (run_id, cancel) = app.keys.push.start_run(aliases.len());
853
854    app.notify_progress(crate::messages::key_push_in_progress(
855        &key_info.name,
856        aliases.len(),
857    ));
858
859    let config_path = app.hosts_state.ssh_config.path.clone();
860    let tx = events_tx.clone();
861    let pubkey_payload = pubkey;
862    let handle = std::thread::Builder::new()
863        .name("key-push".into())
864        .spawn(move || {
865            for alias in aliases {
866                if cancel.load(Ordering::Relaxed) {
867                    break;
868                }
869                let outcome =
870                    crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
871                let _ = tx.send(AppEvent::KeyPushResult {
872                    run_id,
873                    result: crate::key_push::KeyPushResult { alias, outcome },
874                });
875            }
876        });
877    match handle {
878        Ok(h) => {
879            app.keys.push.worker = Some(h);
880        }
881        Err(e) => {
882            log::error!("[purple] key_push: failed to spawn worker: {}", e);
883            // Drop the progress toast through the status-center invariant
884            // so the user does not see "Pushing..." stuck under the
885            // failure message.
886            app.status_center.clear_sticky_status();
887            app.notify_error(crate::messages::key_push_thread_spawn_failed());
888            app.keys.push.clear_inflight_state();
889        }
890    }
891}
892
893#[cfg(test)]
894mod key_push_confirm_tests {
895    //! Coverage for the gate functions wrapping the push-worker spawn.
896    //! Every test exercises a guard path (already-running, missing pubkey,
897    //! certificate key, empty selection, return-to-picker) and asserts the
898    //! observable state. The happy-spawn path is intentionally not unit
899    //! tested here because it forks an ssh subprocess; that path is
900    //! covered by the event-loop tests against the run-completion flow.
901    use super::*;
902    use crate::ssh_config::model::SshConfigFile;
903    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
904
905    fn make_app() -> (App, std::path::PathBuf) {
906        let scratch = tempfile::tempdir().expect("tempdir").keep();
907        crate::preferences::set_path_override(scratch.join("preferences"));
908        crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
909        let config = SshConfigFile {
910            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
911            path: scratch.join("test_config"),
912            crlf: false,
913            bom: false,
914        };
915        let mut app = App::new(config);
916        // Seed a non-cert key whose .pub file lives in the scratch dir so
917        // `read_pubkey_file` succeeds via the override path.
918        let pub_path = scratch.join("id_test.pub");
919        std::fs::write(
920            &pub_path,
921            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
922        )
923        .unwrap();
924        app.keys.list.push(crate::ssh_keys::SshKeyInfo {
925            name: "id_test".into(),
926            display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
927            key_type: "ED25519".into(),
928            bits: "256".into(),
929            fingerprint: String::new(),
930            comment: "test@host".into(),
931            linked_hosts: vec![],
932            bishop_art: String::new(),
933            strength_score: 95,
934            encrypted: false,
935            agent_loaded: false,
936            is_certificate: false,
937            mtime_ts: None,
938        });
939        (app, scratch)
940    }
941
942    fn k(code: KeyCode) -> KeyEvent {
943        KeyEvent::new(code, KeyModifiers::NONE)
944    }
945
946    #[test]
947    fn n_returns_to_picker_with_key_index_preserved() {
948        let (mut app, _scratch) = make_app();
949        app.keys.push.committed = vec!["h1".into()];
950        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
951        let (tx, _rx) = mpsc::channel();
952        handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
953        match app.screen {
954            Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
955            ref other => panic!("expected KeyPushPicker, got {:?}", other),
956        }
957        assert!(
958            app.keys.push.committed.is_empty(),
959            "n should drop the frozen selection"
960        );
961    }
962
963    #[test]
964    fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
965        let (mut app, _scratch) = make_app();
966        app.keys.push.committed = vec!["h1".into()];
967        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
968        let (tx, _rx) = mpsc::channel();
969        handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
970        assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
971    }
972
973    #[test]
974    fn start_rejects_when_a_previous_run_is_still_in_flight() {
975        let (mut app, _scratch) = make_app();
976        app.keys.push.expected_count = 2;
977        app.keys.push.results.push(crate::key_push::KeyPushResult {
978            alias: "h1".into(),
979            outcome: crate::key_push::KeyPushOutcome::Appended,
980        });
981        let (tx, _rx) = mpsc::channel();
982        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
983        assert_eq!(
984            app.keys.push.expected_count, 2,
985            "guard must not reset in-flight state"
986        );
987        let toast = app.status_center.toast.as_ref().expect("toast set");
988        assert!(
989            toast.text.contains("already running"),
990            "expected 'already running' warning, got: {}",
991            toast.text
992        );
993    }
994
995    #[test]
996    fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
997        let (mut app, _scratch) = make_app();
998        let (tx, _rx) = mpsc::channel();
999        start_key_push(&mut app, 0, Vec::new(), &tx);
1000        assert_eq!(app.keys.push.expected_count, 0);
1001        assert!(app.keys.push.worker.is_none());
1002        let toast = app.status_center.toast.as_ref().expect("toast set");
1003        assert!(toast.is_error());
1004    }
1005
1006    #[test]
1007    fn start_rejects_certificate_key() {
1008        let (mut app, _scratch) = make_app();
1009        app.keys.list[0].is_certificate = true;
1010        let (tx, _rx) = mpsc::channel();
1011        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1012        assert_eq!(app.keys.push.expected_count, 0);
1013        assert!(app.keys.push.worker.is_none());
1014        let toast = app.status_center.toast.as_ref().expect("toast set");
1015        assert!(toast.is_error());
1016        assert!(toast.text.contains("Certificates"));
1017    }
1018
1019    #[test]
1020    fn start_rejects_missing_pubkey_file() {
1021        let (mut app, _scratch) = make_app();
1022        app.keys.list[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1023        let (tx, _rx) = mpsc::channel();
1024        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1025        assert_eq!(app.keys.push.expected_count, 0);
1026        let toast = app.status_center.toast.as_ref().expect("toast set");
1027        assert!(toast.is_error());
1028    }
1029
1030    #[test]
1031    fn start_rejects_invalid_pubkey_content() {
1032        let (mut app, scratch) = make_app();
1033        // Multi-line pubkey: the canonical command-injection PoC. Must be
1034        // rejected without spawning the worker.
1035        let pub_path = scratch.join("id_bad.pub");
1036        std::fs::write(
1037            &pub_path,
1038            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1039        )
1040        .unwrap();
1041        app.keys.list[0].display_path = pub_path.with_extension("").to_string_lossy().into_owned();
1042        app.keys.list[0].name = "id_bad".into();
1043        let (tx, _rx) = mpsc::channel();
1044        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1045        assert_eq!(app.keys.push.expected_count, 0);
1046        assert!(app.keys.push.worker.is_none());
1047        let toast = app.status_center.toast.as_ref().expect("toast set");
1048        assert!(toast.is_error());
1049        assert!(toast.text.contains("validation"));
1050    }
1051}