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