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            app.vault.signing_cancel = None;
449            app.vault.sign_thread = None;
450            app.notify_error(crate::messages::vault_spawn_failed(&e));
451        }
452    }
453}
454
455pub(super) fn remove_in_flight(
456    set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
457    alias: &str,
458) {
459    // On mutex poison, recover the inner value and remove only the target alias.
460    // Do NOT clear the entire set. other in-flight aliases are still owned by
461    // live worker iterations and clearing them would allow duplicate signs.
462    let mut guard = match set.lock() {
463        Ok(g) => g,
464        Err(p) => p.into_inner(),
465    };
466    guard.remove(alias);
467}
468
469pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
470    let Screen::ConfirmHostKeyReset {
471        alias,
472        hostname,
473        known_hosts_path,
474        askpass,
475    } = &app.screen
476    else {
477        return;
478    };
479    let alias = alias.clone();
480    let hostname = hostname.clone();
481    let known_hosts_path = known_hosts_path.clone();
482    let askpass = askpass.clone();
483    // Host key reset wipes the host's known_hosts entry. uniform y/n/Esc
484    // contract via the central router so stray keys cannot trigger it.
485    match route_confirm_key(key) {
486        ConfirmAction::Yes => {
487            let output = std::process::Command::new("ssh-keygen")
488                .arg("-R")
489                .arg(&hostname)
490                .arg("-f")
491                .arg(&known_hosts_path)
492                .output();
493
494            match output {
495                Ok(result) if result.status.success() => {
496                    app.notify(crate::messages::removed_host_key(&hostname));
497                    if app.demo_mode {
498                        app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
499                    } else {
500                        app.ui.queue_connect(alias, askpass);
501                    }
502                }
503                Ok(result) => {
504                    let stderr = String::from_utf8_lossy(&result.stderr);
505                    app.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
506                }
507                Err(e) => {
508                    app.notify_error(crate::messages::ssh_keygen_failed(&e));
509                }
510            }
511            app.set_screen(Screen::HostList);
512        }
513        ConfirmAction::No => {
514            app.set_screen(Screen::HostList);
515        }
516        ConfirmAction::Ignored => {}
517    }
518}
519
520/// Confirm handler for `K` (kick = restart). On Yes, queues a
521/// `ContainerActionKind::Restart` request; the main loop picks it
522/// up, fires the SSH command, and emits a result event. On No or
523/// Esc, the screen drops without side effects.
524pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
525    let Screen::ConfirmContainerRestart {
526        alias,
527        container_id,
528        container_name,
529        ..
530    } = &app.screen
531    else {
532        return;
533    };
534    let alias = alias.clone();
535    let container_id = container_id.clone();
536    let container_name = container_name.clone();
537    match route_confirm_key(key) {
538        ConfirmAction::Yes => {
539            queue_container_action(
540                app,
541                alias,
542                container_id,
543                container_name,
544                crate::containers::ContainerAction::Restart,
545            );
546            app.set_screen(Screen::HostList);
547        }
548        ConfirmAction::No => {
549            app.set_screen(Screen::HostList);
550        }
551        ConfirmAction::Ignored => {}
552    }
553}
554
555/// Confirm handler for `S` (stop). Same shape as restart; the action
556/// kind differs and so does the destructive wording in the dialog
557/// body.
558pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
559    let Screen::ConfirmContainerStop {
560        alias,
561        container_id,
562        container_name,
563        ..
564    } = &app.screen
565    else {
566        return;
567    };
568    let alias = alias.clone();
569    let container_id = container_id.clone();
570    let container_name = container_name.clone();
571    match route_confirm_key(key) {
572        ConfirmAction::Yes => {
573            queue_container_action(
574                app,
575                alias,
576                container_id,
577                container_name,
578                crate::containers::ContainerAction::Stop,
579            );
580            app.set_screen(Screen::HostList);
581        }
582        ConfirmAction::No => {
583            app.set_screen(Screen::HostList);
584        }
585        ConfirmAction::Ignored => {}
586    }
587}
588
589/// Confirm handler for `Ctrl-K` (stack kick). Iterates the stored
590/// member list and queues a Restart for each through the same drain
591/// mechanism that powers single-container restart. The drain
592/// processes one request per tick, giving a sequential cadence.
593pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
594    let Screen::ConfirmStackRestart { alias, members, .. } = &app.screen else {
595        return;
596    };
597    let alias = alias.clone();
598    let members = members.clone();
599    match route_confirm_key(key) {
600        ConfirmAction::Yes => {
601            for m in members {
602                queue_container_action(
603                    app,
604                    alias.clone(),
605                    m.container_id,
606                    m.container_name,
607                    crate::containers::ContainerAction::Restart,
608                );
609            }
610            app.set_screen(Screen::HostList);
611        }
612        ConfirmAction::No => {
613            app.set_screen(Screen::HostList);
614        }
615        ConfirmAction::Ignored => {}
616    }
617}
618
619/// Confirm handler for `K` on a host-divider row in the containers
620/// overview. Iterates every running container of the host and queues
621/// a Restart, regardless of compose project. Mirrors the stack-restart
622/// drain. one request per tick keeps remote SSH sane.
623pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
624    let Screen::ConfirmHostRestartAll { alias, members } = &app.screen else {
625        return;
626    };
627    let alias = alias.clone();
628    let members = members.clone();
629    match route_confirm_key(key) {
630        ConfirmAction::Yes => {
631            for m in members {
632                queue_container_action(
633                    app,
634                    alias.clone(),
635                    m.container_id,
636                    m.container_name,
637                    crate::containers::ContainerAction::Restart,
638                );
639            }
640            app.set_screen(Screen::HostList);
641        }
642        ConfirmAction::No => {
643            app.set_screen(Screen::HostList);
644        }
645        ConfirmAction::Ignored => {}
646    }
647}
648
649/// Confirm handler for `S` on a host-divider row. Stops every running
650/// container on the host. Same drain shape as host-restart.
651pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
652    let Screen::ConfirmHostStopAll { alias, members } = &app.screen else {
653        return;
654    };
655    let alias = alias.clone();
656    let members = members.clone();
657    match route_confirm_key(key) {
658        ConfirmAction::Yes => {
659            for m in members {
660                queue_container_action(
661                    app,
662                    alias.clone(),
663                    m.container_id,
664                    m.container_name,
665                    crate::containers::ContainerAction::Stop,
666                );
667            }
668            app.set_screen(Screen::HostList);
669        }
670        ConfirmAction::No => {
671            app.set_screen(Screen::HostList);
672        }
673        ConfirmAction::Ignored => {}
674    }
675}
676
677fn queue_container_action(
678    app: &mut App,
679    alias: String,
680    container_id: String,
681    container_name: String,
682    action: crate::containers::ContainerAction,
683) {
684    let Some(entry) = app.container_state.cache.get(&alias) else {
685        log::debug!(
686            "[purple] container_action: queue aborted, no cache for alias={}",
687            alias
688        );
689        return;
690    };
691    let runtime = entry.runtime;
692    let askpass = app
693        .hosts_state
694        .list
695        .iter()
696        .find(|h| h.alias == alias)
697        .and_then(|h| h.askpass.clone());
698    log::info!(
699        "[purple] container_action queued: alias={} id={} action={:?}",
700        alias,
701        container_id,
702        action
703    );
704    app.container_state
705        .queue_action(crate::app::ContainerActionRequest {
706            alias,
707            askpass,
708            runtime,
709            container_id,
710            container_name,
711            action,
712        });
713}
714
715/// Confirm for the `p` push action from the Keys tab. Stakes test:
716/// pushing modifies remote `authorized_keys`, so the footer uses
717/// action verbs (`push` / `keep`) and we only accept y/n/Esc.
718pub(super) fn handle_key_push_key(
719    app: &mut App,
720    key: KeyEvent,
721    events_tx: &mpsc::Sender<AppEvent>,
722) {
723    match route_confirm_key(key) {
724        ConfirmAction::Yes => {
725            let key_index = match &app.screen {
726                Screen::ConfirmKeyPush { key_index } => *key_index,
727                _ => return,
728            };
729            let aliases = std::mem::take(&mut app.keys.push.committed);
730            app.set_screen(Screen::HostList);
731            start_key_push(app, key_index, aliases, events_tx);
732        }
733        ConfirmAction::No => {
734            // Return to the picker with the selection still intact so the
735            // user can refine it.
736            let key_index = match &app.screen {
737                Screen::ConfirmKeyPush { key_index } => *key_index,
738                _ => return,
739            };
740            app.keys.push.committed.clear();
741            app.set_screen(Screen::KeyPushPicker { key_index });
742        }
743        ConfirmAction::Ignored => {}
744    }
745}
746
747/// Spawn the background push worker. Reads the pubkey from disk on the
748/// main thread (cheap) so we surface an early error toast before
749/// committing to the run. On read failure we abort and stay on
750/// HostList. Refuses to start a second push while a first is still in
751/// flight (`expected_count > 0`); the user must press Esc to cancel
752/// before triggering another run.
753fn start_key_push(
754    app: &mut App,
755    key_index: usize,
756    aliases: Vec<String>,
757    events_tx: &mpsc::Sender<AppEvent>,
758) {
759    // Refuse second push while a previous run still has live state OR a
760    // worker handle that has not been observed to finish. Belt-and-braces:
761    // expected_count protects the in-flight branch, worker.is_finished()
762    // protects the post-cancel branch where the worker is still draining
763    // but its results no longer count toward any expected total.
764    if app.keys.push.expected_count > 0
765        || app
766            .keys
767            .push
768            .worker
769            .as_ref()
770            .is_some_and(|h| !h.is_finished())
771    {
772        log::debug!(
773            "[purple] key_push: rejected second push, run already in progress ({} of {})",
774            app.keys.push.results.len(),
775            app.keys.push.expected_count
776        );
777        app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
778        return;
779    }
780    if aliases.is_empty() {
781        log::debug!("[purple] key_push: rejected, no aliases committed");
782        app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
783        return;
784    }
785    let Some(key_info) = app.keys.list.get(key_index).cloned() else {
786        return;
787    };
788    if key_info.is_certificate {
789        app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
790        return;
791    }
792    let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
793    let raw = match crate::key_push::read_pubkey_file(&pub_path) {
794        Ok(s) => s,
795        Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
796            log::warn!(
797                "[purple] key_push: pubkey too large path={} bytes={}",
798                pub_path.display(),
799                n
800            );
801            app.notify_error(crate::messages::key_push_pubkey_too_large(
802                &key_info.name,
803                n,
804            ));
805            return;
806        }
807        Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
808            log::warn!(
809                "[purple] key_push: pubkey not a regular file path={}",
810                pub_path.display()
811            );
812            app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
813            return;
814        }
815        Err(_) => {
816            // Other validation variants are unreachable here (read_pubkey_file
817            // only returns TooLarge / NotARegularFile / IO collapsed into
818            // NotARegularFile). Defensive fallthrough.
819            app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
820            return;
821        }
822    };
823    let pubkey = match crate::key_push::validate_pubkey(&raw) {
824        Ok(s) => s,
825        Err(err) => {
826            let detail = match &err {
827                crate::key_push::PubkeyValidationError::Empty => "file is empty",
828                crate::key_push::PubkeyValidationError::MultiLine => {
829                    "must be a single line; multi-line input is rejected"
830                }
831                crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
832                    "key algorithm not allowed for static push"
833                }
834                crate::key_push::PubkeyValidationError::MalformedBase64 => {
835                    "base64 key body did not parse"
836                }
837                _ => "unexpected format",
838            };
839            log::warn!(
840                "[purple] key_push: invalid pubkey path={} err={:?}",
841                pub_path.display(),
842                err
843            );
844            app.notify_error(crate::messages::key_push_invalid_pubkey(
845                &key_info.name,
846                detail,
847            ));
848            return;
849        }
850    };
851
852    // Reset accumulators and start a new run.
853    let (run_id, cancel) = app.keys.push.start_run(aliases.len());
854
855    app.notify_progress(crate::messages::key_push_in_progress(
856        &key_info.name,
857        aliases.len(),
858    ));
859
860    let config_path = app.hosts_state.ssh_config.path.clone();
861    let tx = events_tx.clone();
862    let pubkey_payload = pubkey;
863    let handle = std::thread::Builder::new()
864        .name("key-push".into())
865        .spawn(move || {
866            for alias in aliases {
867                if cancel.load(Ordering::Relaxed) {
868                    break;
869                }
870                let outcome =
871                    crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
872                let _ = tx.send(AppEvent::KeyPushResult {
873                    run_id,
874                    result: crate::key_push::KeyPushResult { alias, outcome },
875                });
876            }
877        });
878    match handle {
879        Ok(h) => {
880            app.keys.push.worker = Some(h);
881        }
882        Err(e) => {
883            log::error!("[purple] key_push: failed to spawn worker: {}", e);
884            // Drop the progress toast through the status-center invariant
885            // so the user does not see "Pushing..." stuck under the
886            // failure message.
887            app.status_center.clear_sticky_status();
888            app.notify_error(crate::messages::key_push_thread_spawn_failed());
889            app.keys.push.clear_inflight_state();
890        }
891    }
892}
893
894#[cfg(test)]
895mod key_push_confirm_tests {
896    //! Coverage for the gate functions wrapping the push-worker spawn.
897    //! Every test exercises a guard path (already-running, missing pubkey,
898    //! certificate key, empty selection, return-to-picker) and asserts the
899    //! observable state. The happy-spawn path is intentionally not unit
900    //! tested here because it forks an ssh subprocess; that path is
901    //! covered by the event-loop tests against the run-completion flow.
902    use super::*;
903    use crate::ssh_config::model::SshConfigFile;
904    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
905
906    fn make_app() -> (App, std::path::PathBuf) {
907        let scratch = tempfile::tempdir().expect("tempdir").keep();
908        crate::preferences::set_path_override(scratch.join("preferences"));
909        crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
910        let config = SshConfigFile {
911            elements: SshConfigFile::parse_content("Host h1\n  HostName 1.1.1.1\n"),
912            path: scratch.join("test_config"),
913            crlf: false,
914            bom: false,
915        };
916        let mut app = App::new(config);
917        // Seed a non-cert key whose .pub file lives in the scratch dir so
918        // `read_pubkey_file` succeeds via the override path.
919        let pub_path = scratch.join("id_test.pub");
920        std::fs::write(
921            &pub_path,
922            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
923        )
924        .unwrap();
925        app.keys.list.push(crate::ssh_keys::SshKeyInfo {
926            name: "id_test".into(),
927            display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
928            key_type: "ED25519".into(),
929            bits: "256".into(),
930            fingerprint: String::new(),
931            comment: "test@host".into(),
932            linked_hosts: vec![],
933            bishop_art: String::new(),
934            strength_score: 95,
935            encrypted: false,
936            agent_loaded: false,
937            is_certificate: false,
938            mtime_ts: None,
939        });
940        (app, scratch)
941    }
942
943    fn k(code: KeyCode) -> KeyEvent {
944        KeyEvent::new(code, KeyModifiers::NONE)
945    }
946
947    #[test]
948    fn n_returns_to_picker_with_key_index_preserved() {
949        let (mut app, _scratch) = make_app();
950        app.keys.push.committed = vec!["h1".into()];
951        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
952        let (tx, _rx) = mpsc::channel();
953        handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
954        match app.screen {
955            Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
956            ref other => panic!("expected KeyPushPicker, got {:?}", other),
957        }
958        assert!(
959            app.keys.push.committed.is_empty(),
960            "n should drop the frozen selection"
961        );
962    }
963
964    #[test]
965    fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
966        let (mut app, _scratch) = make_app();
967        app.keys.push.committed = vec!["h1".into()];
968        app.screen = Screen::ConfirmKeyPush { key_index: 0 };
969        let (tx, _rx) = mpsc::channel();
970        handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
971        assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
972    }
973
974    #[test]
975    fn start_rejects_when_a_previous_run_is_still_in_flight() {
976        let (mut app, _scratch) = make_app();
977        app.keys.push.expected_count = 2;
978        app.keys.push.results.push(crate::key_push::KeyPushResult {
979            alias: "h1".into(),
980            outcome: crate::key_push::KeyPushOutcome::Appended,
981        });
982        let (tx, _rx) = mpsc::channel();
983        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
984        assert_eq!(
985            app.keys.push.expected_count, 2,
986            "guard must not reset in-flight state"
987        );
988        let toast = app.status_center.toast.as_ref().expect("toast set");
989        assert!(
990            toast.text.contains("already running"),
991            "expected 'already running' warning, got: {}",
992            toast.text
993        );
994    }
995
996    #[test]
997    fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
998        let (mut app, _scratch) = make_app();
999        let (tx, _rx) = mpsc::channel();
1000        start_key_push(&mut app, 0, Vec::new(), &tx);
1001        assert_eq!(app.keys.push.expected_count, 0);
1002        assert!(app.keys.push.worker.is_none());
1003        let toast = app.status_center.toast.as_ref().expect("toast set");
1004        assert!(toast.is_error());
1005    }
1006
1007    #[test]
1008    fn start_rejects_certificate_key() {
1009        let (mut app, _scratch) = make_app();
1010        app.keys.list[0].is_certificate = true;
1011        let (tx, _rx) = mpsc::channel();
1012        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1013        assert_eq!(app.keys.push.expected_count, 0);
1014        assert!(app.keys.push.worker.is_none());
1015        let toast = app.status_center.toast.as_ref().expect("toast set");
1016        assert!(toast.is_error());
1017        assert!(toast.text.contains("Certificates"));
1018    }
1019
1020    #[test]
1021    fn start_rejects_missing_pubkey_file() {
1022        let (mut app, _scratch) = make_app();
1023        app.keys.list[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1024        let (tx, _rx) = mpsc::channel();
1025        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1026        assert_eq!(app.keys.push.expected_count, 0);
1027        let toast = app.status_center.toast.as_ref().expect("toast set");
1028        assert!(toast.is_error());
1029    }
1030
1031    #[test]
1032    fn start_rejects_invalid_pubkey_content() {
1033        let (mut app, scratch) = make_app();
1034        // Multi-line pubkey: the canonical command-injection PoC. Must be
1035        // rejected without spawning the worker.
1036        let pub_path = scratch.join("id_bad.pub");
1037        std::fs::write(
1038            &pub_path,
1039            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1040        )
1041        .unwrap();
1042        app.keys.list[0].display_path = pub_path.with_extension("").to_string_lossy().into_owned();
1043        app.keys.list[0].name = "id_bad".into();
1044        let (tx, _rx) = mpsc::channel();
1045        start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1046        assert_eq!(app.keys.push.expected_count, 0);
1047        assert!(app.keys.push.worker.is_none());
1048        let toast = app.status_center.toast.as_ref().expect("toast set");
1049        assert!(toast.is_error());
1050        assert!(toast.text.contains("validation"));
1051    }
1052}