1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::mpsc;
4
5use crossterm::event::{KeyCode, KeyEvent};
6
7use super::ctx::{Effectful, Effects, Nav, Notify};
8use crate::app::{App, ContainerState, HostState, KeysState, Screen, StatusCenter, UiSelection};
9use crate::event::AppEvent;
10
11struct ConfirmCtx<'a> {
19 screen: &'a mut Screen,
20 effects: Effects,
21}
22
23impl Nav for ConfirmCtx<'_> {
24 fn screen_mut(&mut self) -> &mut Screen {
25 self.screen
26 }
27}
28
29impl Effectful for ConfirmCtx<'_> {
30 fn effects_mut(&mut self) -> &mut Effects {
31 &mut self.effects
32 }
33}
34
35struct KeyPushConfirmCtx<'a> {
43 keys: &'a mut KeysState,
44 screen: &'a mut Screen,
45 effects: Effects,
46}
47
48impl Nav for KeyPushConfirmCtx<'_> {
49 fn screen_mut(&mut self) -> &mut Screen {
50 self.screen
51 }
52}
53
54impl Effectful for KeyPushConfirmCtx<'_> {
55 fn effects_mut(&mut self) -> &mut Effects {
56 &mut self.effects
57 }
58}
59
60struct HostKeyResetCtx<'a> {
67 ui: &'a mut UiSelection,
68 status: &'a mut StatusCenter,
69 screen: &'a mut Screen,
70 demo_mode: bool,
71}
72
73impl Nav for HostKeyResetCtx<'_> {
74 fn screen_mut(&mut self) -> &mut Screen {
75 self.screen
76 }
77}
78
79impl Notify for HostKeyResetCtx<'_> {
80 fn status_mut(&mut self) -> &mut StatusCenter {
81 self.status
82 }
83}
84
85struct ContainerConfirmCtx<'a> {
93 container_state: &'a mut ContainerState,
94 hosts: &'a HostState,
95 screen: &'a mut Screen,
96}
97
98impl Nav for ContainerConfirmCtx<'_> {
99 fn screen_mut(&mut self) -> &mut Screen {
100 self.screen
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ConfirmAction {
117 Yes,
118 No,
119 Ignored,
120}
121
122pub fn route_confirm_key(key: KeyEvent) -> ConfirmAction {
124 match key.code {
125 KeyCode::Char('y') | KeyCode::Char('Y') => ConfirmAction::Yes,
126 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => ConfirmAction::No,
127 _ => ConfirmAction::Ignored,
128 }
129}
130
131pub(super) fn execute_known_hosts_import(app: &mut App) {
133 let config_backup = app.hosts_state.ssh_config().clone();
134 match crate::import::import_from_known_hosts(
135 app.env.paths(),
136 app.hosts_state.ssh_config_mut(),
137 Some("known_hosts"),
138 ) {
139 Ok((imported, skipped, _, _)) => {
140 if imported > 0 {
141 if let Err(e) = app.hosts_state.ssh_config().write() {
142 app.hosts_state.set_ssh_config(config_backup);
143 app.notify_error(crate::messages::failed_to_save(&e));
144 return;
145 }
146 app.reload_hosts();
147 app.notify(crate::messages::imported_hosts(imported, skipped));
148 } else {
149 app.notify(crate::messages::all_hosts_exist(skipped));
150 }
151 app.ui.set_known_hosts_count(0);
152 }
153 Err(e) => {
154 app.notify_error(e);
155 }
156 }
157}
158
159pub(super) fn handle_import_key(app: &mut App, key: KeyEvent) {
160 let effects = {
161 let mut ctx = ConfirmCtx {
162 screen: &mut app.screen,
163 effects: Effects::default(),
164 };
165 match route_confirm_key(key) {
166 ConfirmAction::Yes => {
167 ctx.set_screen(Screen::HostList);
168 ctx.defer(execute_known_hosts_import);
169 }
170 ConfirmAction::No => {
171 ctx.set_screen(Screen::HostList);
172 }
173 ConfirmAction::Ignored => {}
174 }
175 ctx.effects
176 };
177 effects.apply(app);
178}
179
180pub(super) fn handle_purge_stale_key(app: &mut App, key: KeyEvent) {
181 if !matches!(app.screen, Screen::ConfirmPurgeStale) {
182 return;
183 }
184 let route = route_confirm_key(key);
185 if route == ConfirmAction::Ignored {
186 return;
187 }
188 let Some(payload) = app.providers.take_pending_purge() else {
192 app.set_screen(Screen::HostList);
195 return;
196 };
197 let provider = payload.provider;
198 let return_screen = if provider.is_some() {
199 Screen::Providers
200 } else {
201 Screen::HostList
202 };
203 let effects = {
204 let mut ctx = ConfirmCtx {
205 screen: &mut app.screen,
206 effects: Effects::default(),
207 };
208 match route {
209 ConfirmAction::Yes => {
210 ctx.defer(move |app| execute_purge_stale(app, provider.as_deref()));
215 ctx.set_screen(return_screen);
216 }
217 ConfirmAction::No => {
218 ctx.set_screen(return_screen);
219 }
220 ConfirmAction::Ignored => unreachable!(),
221 }
222 ctx.effects
223 };
224 effects.apply(app);
225}
226
227fn execute_purge_stale(app: &mut App, provider: Option<&str>) {
228 let stale = app.hosts_state.ssh_config().stale_hosts();
229 if stale.is_empty() {
230 return;
231 }
232 let targets: Vec<(String, u64)> = if let Some(prov) = provider {
234 stale
235 .into_iter()
236 .filter(|(alias, _)| {
237 app.hosts_state
238 .ssh_config()
239 .host_entries()
240 .iter()
241 .any(|e| e.alias == *alias && e.provider.as_deref() == Some(prov))
242 })
243 .collect()
244 } else {
245 stale
246 };
247 if targets.is_empty() {
248 return;
249 }
250 let config_backup = app.hosts_state.ssh_config().clone();
251 let count = targets.len();
252 for (alias, _) in &targets {
253 app.hosts_state.ssh_config_mut().delete_host(alias);
254 }
255 if let Err(e) = app.hosts_state.ssh_config().write() {
256 app.hosts_state.set_ssh_config(config_backup);
257 app.notify_error(crate::messages::failed_to_save(&e));
258 return;
259 }
260 for (alias, _) in &targets {
262 if let Some(mut tunnel) = app.tunnels.active_remove(alias) {
263 let _ = tunnel.child.kill();
264 let _ = tunnel.child.wait();
265 }
266 }
267 app.hosts_state.clear_undo();
268 app.update_last_modified();
269 app.reload_hosts();
270 log::debug!(
271 "[purple] purged {} stale host(s){}",
272 count,
273 provider
274 .map(|p| format!(" provider={p}"))
275 .unwrap_or_default()
276 );
277 let msg = if let Some(prov) = provider {
278 let display = crate::providers::provider_display_name(prov);
279 format!(
280 "Removed {} stale {} host{}.",
281 count,
282 display,
283 if count == 1 { "" } else { "s" }
284 )
285 } else {
286 format!(
287 "Removed {} stale host{}.",
288 count,
289 if count == 1 { "" } else { "s" }
290 )
291 };
292 app.notify(msg);
293}
294
295pub(super) fn handle_delete_key(app: &mut App, key: KeyEvent) {
303 let Screen::ConfirmDelete { alias } = &app.screen else {
304 return;
305 };
306 let alias = alias.clone();
307 match route_confirm_key(key) {
310 ConfirmAction::Yes => {
311 let siblings = app.hosts_state.ssh_config().siblings_of(&alias);
312
313 if !siblings.is_empty() {
314 app.hosts_state.ssh_config_mut().delete_host(&alias);
323 if let Err(e) = app.hosts_state.ssh_config().write() {
324 app.notify_error(crate::messages::failed_to_save(&e));
327 app.reload_hosts();
328 } else {
329 if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
330 let _ = tunnel.child.kill();
331 let _ = tunnel.child.wait();
332 }
333 app.update_last_modified();
334 app.reload_hosts();
335 log::debug!(
336 "[purple] host alias stripped: alias={alias}, {} sibling(s) kept",
337 siblings.len()
338 );
339 app.notify(crate::messages::siblings_stripped(&alias, siblings.len()));
340 }
341 } else if let Some((element, position)) = app
342 .hosts_state
343 .ssh_config_mut()
344 .delete_host_undoable(&alias)
345 {
346 if let Err(e) = app.hosts_state.ssh_config().write() {
347 app.hosts_state
349 .ssh_config_mut()
350 .insert_host_at(element, position);
351 app.notify_error(crate::messages::failed_to_save(&e));
352 } else {
353 if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
355 let _ = tunnel.child.kill();
356 let _ = tunnel.child.wait();
357 }
358 let mut cert_cleanup_warning: Option<String> = None;
363 if !crate::demo_flag::is_demo()
364 && let Ok(cert_path) =
365 crate::vault_ssh::cert_path_for(app.env().paths(), &alias)
366 {
367 match std::fs::remove_file(&cert_path) {
368 Ok(()) => {}
369 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
370 Err(e) => {
371 cert_cleanup_warning = Some(crate::messages::cert_cleanup_warning(
372 &cert_path.display(),
373 &e,
374 ));
375 }
376 }
377 }
378 app.hosts_state
379 .undo_stack_mut()
380 .push(crate::app::DeletedHost { element, position });
381 if app.hosts_state.undo_stack().len() > 50 {
382 app.hosts_state.undo_stack_mut().remove(0);
383 }
384 app.update_last_modified();
385 app.reload_hosts();
386 log::debug!("[purple] host deleted: alias={alias} (undoable)");
387 if let Some(warning) = cert_cleanup_warning {
388 app.notify_error(warning);
389 } else {
390 app.notify(crate::messages::goodbye_host(&alias));
391 }
392 }
393 } else {
394 app.notify_warning(crate::messages::host_not_found(&alias));
395 }
396 app.set_screen(Screen::HostList);
397 }
398 ConfirmAction::No => {
399 app.set_screen(Screen::HostList);
400 }
401 ConfirmAction::Ignored => {}
402 }
403}
404
405pub(super) fn handle_vault_sign_key(
406 app: &mut App,
407 key: KeyEvent,
408 events_tx: &mpsc::Sender<AppEvent>,
409) {
410 if !matches!(app.screen, Screen::ConfirmVaultSign) {
417 return;
418 }
419 let route = route_confirm_key(key);
420 if route == ConfirmAction::Ignored {
421 return;
422 }
423 let Some(signable) = app.vault.take_pending_sign() else {
426 app.set_screen(Screen::HostList);
429 return;
430 };
431 let effects = {
432 let mut ctx = ConfirmCtx {
433 screen: &mut app.screen,
434 effects: Effects::default(),
435 };
436 match route {
437 ConfirmAction::Yes => {
438 ctx.set_screen(Screen::HostList);
444 let tx = events_tx.clone();
445 ctx.defer(move |app| start_vault_bulk_sign(app, signable, &tx));
446 }
447 ConfirmAction::No => {
448 ctx.set_screen(Screen::HostList);
449 }
450 ConfirmAction::Ignored => unreachable!(),
451 }
452 ctx.effects
453 };
454 effects.apply(app);
455}
456
457fn start_vault_bulk_sign(
460 app: &mut App,
461 signable: Vec<crate::vault_ssh::VaultSignTarget>,
462 events_tx: &mpsc::Sender<AppEvent>,
463) {
464 let total = signable.len();
465 if total == 0 {
466 return;
467 }
468 app.notify_progress(crate::messages::vault_signing_progress(
469 crate::animation::SPINNER_FRAMES[0],
470 0,
471 total,
472 "",
473 ));
474
475 let cancel = Arc::new(AtomicBool::new(false));
476 app.vault.set_signing_cancel(cancel.clone());
477
478 let in_flight = app.vault.sign_in_flight().clone();
479 let tx = events_tx.clone();
480 let env = std::sync::Arc::clone(&app.env);
483 let spawn_result = std::thread::Builder::new()
484 .name("vault-bulk-sign".into())
485 .spawn(move || {
486 let mut signed = 0u32;
487 let mut failed = 0u32;
488 let mut skipped = 0u32;
489 let mut consecutive_failures = 0usize;
490 let mut first_error: Option<String> = None;
491 let mut aborted_message: Option<String> = None;
492
493 for (idx, target) in signable.iter().enumerate() {
494 let crate::vault_ssh::VaultSignTarget {
495 alias,
496 role,
497 certificate_file: cert_file,
498 pubkey,
499 vault_addr,
500 } = target;
501 if cancel.load(Ordering::Relaxed) {
502 break;
503 }
504 let done = idx + 1;
505
506 {
509 let mut set = match in_flight.lock() {
514 Ok(g) => g,
515 Err(p) => p.into_inner(),
516 };
517 if !set.insert(alias.clone()) {
518 skipped += 1;
519 let _ = tx.send(AppEvent::VaultSignProgress {
520 alias: alias.clone(),
521 done,
522 total,
523 });
524 continue;
525 }
526 }
527
528 let _ = tx.send(AppEvent::VaultSignProgress {
529 alias: alias.clone(),
530 done,
531 total,
532 });
533
534 let cert_path =
535 match crate::vault_ssh::resolve_cert_path(env.paths(), alias, cert_file) {
536 Ok(p) => p,
537 Err(e) => {
538 failed += 1;
539 consecutive_failures += 1;
540 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
541 if first_error.is_none() {
542 first_error = Some(scrubbed);
543 }
544 remove_in_flight(&in_flight, alias);
545 if consecutive_failures >= 3 {
546 aborted_message = Some(crate::messages::vault_signing_aborted(
547 failed,
548 first_error.as_deref(),
549 ));
550 break;
551 }
552 continue;
553 }
554 };
555 let status = crate::vault_ssh::check_cert_validity(&env, &cert_path);
556 if !crate::vault_ssh::needs_renewal(&status) {
557 skipped += 1;
558 consecutive_failures = 0;
559 remove_in_flight(&in_flight, alias);
560 continue;
561 }
562
563 let sign_result = crate::vault_ssh::sign_certificate(
564 &env,
565 role,
566 pubkey,
567 alias,
568 vault_addr.as_deref(),
569 );
570 remove_in_flight(&in_flight, alias);
574 match sign_result {
575 Ok(_) => {
576 let _ = tx.send(AppEvent::VaultSignResult {
577 alias: alias.clone(),
578 certificate_file: cert_file.clone(),
579 success: true,
580 message: String::new(),
581 });
582 signed += 1;
583 consecutive_failures = 0;
584 }
585 Err(e) => {
586 let raw = e.to_string();
587 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
588 if first_error.is_none() {
589 first_error = Some(scrubbed.clone());
590 }
591 let _ = tx.send(AppEvent::VaultSignResult {
592 alias: alias.clone(),
593 certificate_file: cert_file.clone(),
594 success: false,
595 message: scrubbed,
596 });
597 failed += 1;
598 consecutive_failures += 1;
599 if consecutive_failures >= 3 {
600 aborted_message = Some(crate::messages::vault_signing_aborted(
601 failed,
602 first_error.as_deref(),
603 ));
604 break;
605 }
606 }
607 }
608 }
609
610 let cancelled = cancel.load(Ordering::Relaxed);
611 let _ = tx.send(AppEvent::VaultSignAllDone {
612 signed,
613 failed,
614 skipped,
615 cancelled,
616 aborted_message,
617 first_error,
618 });
619 });
620 match spawn_result {
621 Ok(handle) => {
622 log::info!("[purple] vault sign thread: spawned");
623 app.vault.set_sign_thread(handle);
624 }
625 Err(e) => {
626 log::warn!("[purple] vault sign thread: spawn failed: {}", e);
630 let _ = app.vault.finalize_signing_run();
631 app.notify_error(crate::messages::vault_spawn_failed(&e));
632 }
633 }
634}
635
636pub(super) fn remove_in_flight(
637 set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
638 alias: &str,
639) {
640 let mut guard = match set.lock() {
644 Ok(g) => g,
645 Err(p) => p.into_inner(),
646 };
647 guard.remove(alias);
648}
649
650pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
651 let mut ctx = HostKeyResetCtx {
652 ui: &mut app.ui,
653 status: &mut app.status_center,
654 screen: &mut app.screen,
655 demo_mode: app.demo_mode,
656 };
657 let Screen::ConfirmHostKeyReset {
658 alias,
659 hostname,
660 known_hosts_path,
661 askpass,
662 } = &*ctx.screen
663 else {
664 return;
665 };
666 let alias = alias.clone();
667 let hostname = hostname.clone();
668 let known_hosts_path = known_hosts_path.clone();
669 let askpass = askpass.clone();
670 match route_confirm_key(key) {
673 ConfirmAction::Yes => {
674 let output = std::process::Command::new("ssh-keygen")
675 .arg("-R")
676 .arg(&hostname)
677 .arg("-f")
678 .arg(&known_hosts_path)
679 .output();
680
681 match output {
682 Ok(result) if result.status.success() => {
683 ctx.notify(crate::messages::removed_host_key(&hostname));
684 if ctx.demo_mode {
685 ctx.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
686 } else {
687 ctx.ui.queue_connect(alias, askpass);
688 }
689 }
690 Ok(result) => {
691 let stderr = String::from_utf8_lossy(&result.stderr);
692 ctx.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
693 }
694 Err(e) => {
695 ctx.notify_error(crate::messages::ssh_keygen_failed(&e));
696 }
697 }
698 ctx.set_screen(Screen::HostList);
699 }
700 ConfirmAction::No => {
701 ctx.set_screen(Screen::HostList);
702 }
703 ConfirmAction::Ignored => {}
704 }
705}
706
707pub(super) fn handle_host_key_trust_key(
716 app: &mut App,
717 key: KeyEvent,
718 events_tx: &mpsc::Sender<AppEvent>,
719) {
720 let Screen::ConfirmHostKeyTrust { alias, retry, .. } = &app.screen else {
721 return;
722 };
723 let alias = alias.clone();
724 let retry = retry.clone();
725 match route_confirm_key(key) {
726 ConfirmAction::Yes => {
727 log::debug!("[purple] host key trust: accepted alias={alias}");
728 let back = super::password_prompt::return_screen(app);
729 app.set_screen(back);
730 super::password_prompt::execute_retry(app, retry, true, events_tx);
731 super::event_loop::key_push::drain_next_key_push_prompt(app);
732 }
733 ConfirmAction::No => {
734 log::debug!("[purple] host key trust: declined alias={alias}");
735 let back = super::password_prompt::return_screen(app);
736 app.set_screen(back);
737 app.notify_warning(crate::messages::host_key_trust::declined(&alias));
738 super::event_loop::key_push::drain_next_key_push_prompt(app);
739 }
740 ConfirmAction::Ignored => {}
741 }
742}
743
744struct ContainerConfirm {
747 alias: String,
748 targets: Vec<(String, String)>,
749 action: crate::containers::ContainerAction,
750}
751
752fn apply_container_confirm(
756 ctx: &mut ContainerConfirmCtx,
757 key: KeyEvent,
758 confirm: ContainerConfirm,
759) {
760 match route_confirm_key(key) {
761 ConfirmAction::Yes => {
762 for (container_id, container_name) in confirm.targets {
763 queue_container_action(
764 ctx,
765 confirm.alias.clone(),
766 container_id,
767 container_name,
768 confirm.action,
769 );
770 }
771 ctx.set_screen(Screen::HostList);
772 }
773 ConfirmAction::No => {
774 ctx.set_screen(Screen::HostList);
775 }
776 ConfirmAction::Ignored => {}
777 }
778}
779
780fn container_confirm_ctx(app: &mut App) -> ContainerConfirmCtx<'_> {
782 ContainerConfirmCtx {
783 container_state: &mut app.container_state,
784 hosts: &app.hosts_state,
785 screen: &mut app.screen,
786 }
787}
788
789pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
791 let mut ctx = container_confirm_ctx(app);
792 let Screen::ConfirmContainerRestart {
793 alias,
794 container_id,
795 container_name,
796 ..
797 } = &*ctx.screen
798 else {
799 return;
800 };
801 let confirm = ContainerConfirm {
802 alias: alias.clone(),
803 targets: vec![(container_id.clone(), container_name.clone())],
804 action: crate::containers::ContainerAction::Restart,
805 };
806 apply_container_confirm(&mut ctx, key, confirm);
807}
808
809pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
811 let mut ctx = container_confirm_ctx(app);
812 let Screen::ConfirmContainerStop {
813 alias,
814 container_id,
815 container_name,
816 ..
817 } = &*ctx.screen
818 else {
819 return;
820 };
821 let confirm = ContainerConfirm {
822 alias: alias.clone(),
823 targets: vec![(container_id.clone(), container_name.clone())],
824 action: crate::containers::ContainerAction::Stop,
825 };
826 apply_container_confirm(&mut ctx, key, confirm);
827}
828
829pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
833 bulk_confirm_key(
834 app,
835 key,
836 Screen::ConfirmStackRestart,
837 crate::containers::ContainerAction::Restart,
838 );
839}
840
841pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
844 bulk_confirm_key(
845 app,
846 key,
847 Screen::ConfirmHostRestartAll,
848 crate::containers::ContainerAction::Restart,
849 );
850}
851
852pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
855 bulk_confirm_key(
856 app,
857 key,
858 Screen::ConfirmHostStopAll,
859 crate::containers::ContainerAction::Stop,
860 );
861}
862
863fn bulk_confirm_key(
869 app: &mut App,
870 key: KeyEvent,
871 expected_screen: Screen,
872 action: crate::containers::ContainerAction,
873) {
874 if app.screen != expected_screen {
875 return;
876 }
877 if route_confirm_key(key) == ConfirmAction::Ignored {
878 return;
879 }
880 let Some(payload) = app.containers_overview.take_pending_bulk_confirm() else {
881 app.set_screen(Screen::HostList);
885 return;
886 };
887 let targets: Vec<(String, String)> = payload
888 .members
889 .iter()
890 .map(|m| (m.container_id.clone(), m.container_name.clone()))
891 .collect();
892 let confirm = ContainerConfirm {
893 alias: payload.alias,
894 targets,
895 action,
896 };
897 let mut ctx = container_confirm_ctx(app);
898 apply_container_confirm(&mut ctx, key, confirm);
899}
900
901fn queue_container_action(
902 ctx: &mut ContainerConfirmCtx,
903 alias: String,
904 container_id: String,
905 container_name: String,
906 action: crate::containers::ContainerAction,
907) {
908 let Some(entry) = ctx.container_state.cache_entry(&alias) else {
909 log::debug!(
910 "[purple] container_action: queue aborted, no cache for alias={}",
911 alias
912 );
913 return;
914 };
915 let runtime = entry.runtime;
916 let askpass = ctx
917 .hosts
918 .list()
919 .iter()
920 .find(|h| h.alias == alias)
921 .and_then(|h| h.askpass.clone());
922 log::info!(
923 "[purple] container_action queued: alias={} id={} action={:?}",
924 alias,
925 container_id,
926 action
927 );
928 ctx.container_state
929 .queue_action(crate::app::ContainerActionRequest {
930 alias,
931 askpass,
932 runtime,
933 container_id,
934 container_name,
935 action,
936 });
937}
938
939pub(super) fn handle_key_push_key(
943 app: &mut App,
944 key: KeyEvent,
945 events_tx: &mpsc::Sender<AppEvent>,
946) {
947 let effects = {
948 let mut ctx = KeyPushConfirmCtx {
949 keys: &mut app.keys,
950 screen: &mut app.screen,
951 effects: Effects::default(),
952 };
953 match route_confirm_key(key) {
954 ConfirmAction::Yes => {
955 let key_index = match &*ctx.screen {
956 Screen::ConfirmKeyPush { key_index } => *key_index,
957 _ => return,
958 };
959 let aliases = std::mem::take(&mut ctx.keys.push_mut().committed);
960 ctx.set_screen(Screen::HostList);
961 let tx = events_tx.clone();
966 ctx.defer(move |app| {
967 start_key_push(app, key_index, aliases, false, &tx);
968 });
969 }
970 ConfirmAction::No => {
971 let key_index = match &*ctx.screen {
974 Screen::ConfirmKeyPush { key_index } => *key_index,
975 _ => return,
976 };
977 ctx.keys.push_mut().committed.clear();
978 ctx.set_screen(Screen::KeyPushPicker { key_index });
979 }
980 ConfirmAction::Ignored => {}
981 }
982 ctx.effects
983 };
984 effects.apply(app);
985}
986
987pub(super) fn handle_run_snippet_confirm_key(
992 app: &mut App,
993 key: KeyEvent,
994 events_tx: &mpsc::Sender<AppEvent>,
995) {
996 match route_confirm_key(key) {
997 ConfirmAction::Yes => {
998 super::snippet::run_flow_snippet(app, events_tx);
999 }
1000 ConfirmAction::No => {
1001 app.set_screen(Screen::SnippetHostPicker);
1002 }
1003 ConfirmAction::Ignored => {}
1004 }
1005}
1006
1007pub(crate) fn push_auth_for(
1011 app: &App,
1012 alias: &str,
1013 trust_new_host_key: bool,
1014) -> crate::key_push::PushAuth {
1015 crate::key_push::PushAuth {
1016 askpass: app.askpass_source_for(alias),
1017 session_password: app.session_password_for(alias),
1018 bw_session: app.bw_session.clone(),
1019 trust_new_host_key,
1020 security_key: app.security_key_run_for(alias, crate::security_key::PinFlow::KeyPush),
1021 paths: app.env().paths().cloned(),
1022 }
1023}
1024
1025pub(crate) fn start_key_push(
1036 app: &mut App,
1037 key_index: usize,
1038 aliases: Vec<String>,
1039 trust_new_host_key: bool,
1040 events_tx: &mpsc::Sender<AppEvent>,
1041) -> bool {
1042 if app.keys.push().expected_count > 0
1048 || app
1049 .keys
1050 .push()
1051 .worker
1052 .as_ref()
1053 .is_some_and(|h| !h.is_finished())
1054 {
1055 log::debug!(
1056 "[purple] key_push: rejected second push, run already in progress ({} of {})",
1057 app.keys.push().results.len(),
1058 app.keys.push().expected_count
1059 );
1060 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
1061 return false;
1062 }
1063 if aliases.is_empty() {
1064 log::debug!("[purple] key_push: rejected, no aliases committed");
1065 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
1066 return false;
1067 }
1068 let Some(key_info) = app.keys.list().get(key_index).cloned() else {
1069 return false;
1070 };
1071 if key_info.is_certificate {
1072 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
1073 return false;
1074 }
1075 let pub_path = crate::key_push::pubkey_path_for(app.env.paths(), &key_info.display_path);
1076 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
1077 Ok(s) => s,
1078 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
1079 log::warn!(
1080 "[config] key_push: pubkey too large path={} bytes={}",
1081 pub_path.display(),
1082 n
1083 );
1084 app.notify_error(crate::messages::key_push_pubkey_too_large(
1085 &key_info.name,
1086 n,
1087 ));
1088 return false;
1089 }
1090 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
1091 log::warn!(
1092 "[config] key_push: pubkey not a regular file path={}",
1093 pub_path.display()
1094 );
1095 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
1096 return false;
1097 }
1098 Err(_) => {
1099 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
1103 return false;
1104 }
1105 };
1106 let pubkey = match crate::key_push::validate_pubkey(&raw) {
1107 Ok(s) => s,
1108 Err(err) => {
1109 let detail = match &err {
1110 crate::key_push::PubkeyValidationError::Empty => "file is empty",
1111 crate::key_push::PubkeyValidationError::MultiLine => {
1112 "must be a single line; multi-line input is rejected"
1113 }
1114 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
1115 "key algorithm not allowed for static push"
1116 }
1117 crate::key_push::PubkeyValidationError::MalformedBase64 => {
1118 "base64 key body did not parse"
1119 }
1120 _ => "unexpected format",
1121 };
1122 log::warn!(
1123 "[config] key_push: invalid pubkey path={} err={:?}",
1124 pub_path.display(),
1125 err
1126 );
1127 app.notify_error(crate::messages::key_push_invalid_pubkey(
1128 &key_info.name,
1129 detail,
1130 ));
1131 return false;
1132 }
1133 };
1134
1135 let (run_id, cancel) = app.keys.push_mut().start_run(
1137 aliases.len(),
1138 key_info.display_path.clone(),
1139 trust_new_host_key,
1140 );
1141 let inflight = app.keys.push().inflight.clone();
1142
1143 app.notify_progress(crate::messages::key_push_in_progress(
1144 &key_info.name,
1145 aliases.len(),
1146 ));
1147
1148 let targets: Vec<(String, crate::key_push::PushAuth)> = aliases
1152 .into_iter()
1153 .map(|alias| {
1154 let auth = push_auth_for(app, &alias, trust_new_host_key);
1155 (alias, auth)
1156 })
1157 .collect();
1158
1159 let config_path = app.hosts_state.ssh_config().path.clone();
1160 let tx = events_tx.clone();
1161 let pubkey_payload = pubkey;
1162 let handle = std::thread::Builder::new()
1163 .name("key-push".into())
1164 .spawn(move || {
1165 for (alias, auth) in targets {
1166 if cancel.load(Ordering::Relaxed) {
1167 break;
1168 }
1169 let outcome = crate::key_push::push_to_host(
1170 &pubkey_payload,
1171 &alias,
1172 &config_path,
1173 &auth,
1174 &cancel,
1175 &inflight,
1176 );
1177 let _ = tx.send(AppEvent::KeyPushResult {
1178 run_id,
1179 result: crate::key_push::KeyPushResult { alias, outcome },
1180 });
1181 }
1182 });
1183 match handle {
1184 Ok(h) => {
1185 app.keys.push_mut().worker = Some(h);
1186 true
1187 }
1188 Err(e) => {
1189 log::error!("[purple] key_push: failed to spawn worker: {}", e);
1190 app.status_center.clear_sticky_status();
1194 app.notify_error(crate::messages::key_push_thread_spawn_failed());
1195 app.keys.push_mut().clear_inflight_state();
1196 false
1197 }
1198 }
1199}
1200
1201#[cfg(test)]
1202mod key_push_confirm_tests {
1203 use super::*;
1210 use crate::ssh_config::model::SshConfigFile;
1211 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1212
1213 fn make_app() -> (App, std::path::PathBuf) {
1214 let scratch = tempfile::tempdir().expect("tempdir").keep();
1215 let config = SshConfigFile {
1216 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1217 path: scratch.join("test_config"),
1218 crlf: false,
1219 bom: false,
1220 };
1221 let mut app = App::new(config);
1222 let pub_path = scratch.join("id_test.pub");
1225 std::fs::write(
1226 &pub_path,
1227 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
1228 )
1229 .unwrap();
1230 app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1231 name: "id_test".into(),
1232 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
1233 key_type: "ED25519".into(),
1234 bits: "256".into(),
1235 fingerprint: String::new(),
1236 comment: "test@host".into(),
1237 linked_hosts: vec![],
1238 bishop_art: String::new(),
1239 strength_score: 95,
1240 encrypted: false,
1241 agent_loaded: false,
1242 is_certificate: false,
1243 mtime_ts: None,
1244 });
1245 (app, scratch)
1246 }
1247
1248 fn k(code: KeyCode) -> KeyEvent {
1249 KeyEvent::new(code, KeyModifiers::NONE)
1250 }
1251
1252 #[test]
1253 fn n_returns_to_picker_with_key_index_preserved() {
1254 let (mut app, _scratch) = make_app();
1255 app.keys.push_mut().committed = vec!["h1".into()];
1256 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1257 let (tx, _rx) = mpsc::channel();
1258 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
1259 match app.screen {
1260 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
1261 ref other => panic!("expected KeyPushPicker, got {:?}", other),
1262 }
1263 assert!(
1264 app.keys.push().committed.is_empty(),
1265 "n should drop the frozen selection"
1266 );
1267 }
1268
1269 #[test]
1270 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
1271 let (mut app, _scratch) = make_app();
1272 app.keys.push_mut().committed = vec!["h1".into()];
1273 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1274 let (tx, _rx) = mpsc::channel();
1275 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
1276 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
1277 }
1278
1279 #[test]
1280 fn start_rejects_when_a_previous_run_is_still_in_flight() {
1281 let (mut app, _scratch) = make_app();
1282 app.keys.push_mut().expected_count = 2;
1283 app.keys
1284 .push_mut()
1285 .results
1286 .push(crate::key_push::KeyPushResult {
1287 alias: "h1".into(),
1288 outcome: crate::key_push::KeyPushOutcome::Appended,
1289 });
1290 let (tx, _rx) = mpsc::channel();
1291 start_key_push(&mut app, 0, vec!["h1".into()], false, &tx);
1292 assert_eq!(
1293 app.keys.push().expected_count,
1294 2,
1295 "guard must not reset in-flight state"
1296 );
1297 let toast = app.status_center.toast().expect("toast set");
1298 assert!(
1299 toast.text.contains("already running"),
1300 "expected 'already running' warning, got: {}",
1301 toast.text
1302 );
1303 }
1304
1305 #[test]
1306 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1307 let (mut app, _scratch) = make_app();
1308 let (tx, _rx) = mpsc::channel();
1309 start_key_push(&mut app, 0, Vec::new(), false, &tx);
1310 assert_eq!(app.keys.push().expected_count, 0);
1311 assert!(app.keys.push().worker.is_none());
1312 let toast = app.status_center.toast().expect("toast set");
1313 assert!(toast.is_error());
1314 }
1315
1316 #[test]
1317 fn start_rejects_certificate_key() {
1318 let (mut app, _scratch) = make_app();
1319 app.keys.list_mut()[0].is_certificate = true;
1320 let (tx, _rx) = mpsc::channel();
1321 start_key_push(&mut app, 0, vec!["h1".into()], false, &tx);
1322 assert_eq!(app.keys.push().expected_count, 0);
1323 assert!(app.keys.push().worker.is_none());
1324 let toast = app.status_center.toast().expect("toast set");
1325 assert!(toast.is_error());
1326 assert!(toast.text.contains("Certificates"));
1327 }
1328
1329 #[test]
1330 fn start_rejects_missing_pubkey_file() {
1331 let (mut app, _scratch) = make_app();
1332 app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1333 let (tx, _rx) = mpsc::channel();
1334 start_key_push(&mut app, 0, vec!["h1".into()], false, &tx);
1335 assert_eq!(app.keys.push().expected_count, 0);
1336 let toast = app.status_center.toast().expect("toast set");
1337 assert!(toast.is_error());
1338 }
1339
1340 #[test]
1341 fn start_rejects_invalid_pubkey_content() {
1342 let (mut app, scratch) = make_app();
1343 let pub_path = scratch.join("id_bad.pub");
1346 std::fs::write(
1347 &pub_path,
1348 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1349 )
1350 .unwrap();
1351 app.keys.list_mut()[0].display_path =
1352 pub_path.with_extension("").to_string_lossy().into_owned();
1353 app.keys.list_mut()[0].name = "id_bad".into();
1354 let (tx, _rx) = mpsc::channel();
1355 start_key_push(&mut app, 0, vec!["h1".into()], false, &tx);
1356 assert_eq!(app.keys.push().expected_count, 0);
1357 assert!(app.keys.push().worker.is_none());
1358 let toast = app.status_center.toast().expect("toast set");
1359 assert!(toast.is_error());
1360 assert!(toast.text.contains("validation"));
1361 }
1362
1363 fn trust_app() -> App {
1366 let scratch = tempfile::tempdir().expect("tempdir").keep();
1367 let config = SshConfigFile {
1368 elements: SshConfigFile::parse_content("Host h1\n HostName db.example.com\n"),
1369 path: scratch.join("cfg"),
1370 crlf: false,
1371 bom: false,
1372 };
1373 let mut app = App::new(config);
1374 app.screen = Screen::ConfirmHostKeyTrust {
1375 alias: "h1".into(),
1376 hostname: "db.example.com".into(),
1377 retry: crate::app::PendingRetry::KeyPush {
1378 key_path: "~/.ssh/id_test".into(),
1379 alias: "h1".into(),
1380 },
1381 };
1382 app
1383 }
1384
1385 fn seed_key(app: &mut App) {
1386 app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1387 name: "id_test".into(),
1388 display_path: "~/.ssh/id_test".into(),
1389 key_type: "ED25519".into(),
1390 bits: "256".into(),
1391 fingerprint: String::new(),
1392 comment: String::new(),
1393 linked_hosts: vec![],
1394 bishop_art: String::new(),
1395 strength_score: 95,
1396 encrypted: false,
1397 agent_loaded: false,
1398 is_certificate: false,
1399 mtime_ts: None,
1400 });
1401 }
1402
1403 #[test]
1404 fn trust_accepted_closes_the_dialog() {
1405 let mut app = trust_app();
1408 let (tx, _rx) = mpsc::channel();
1409 handle_host_key_trust_key(&mut app, k(KeyCode::Char('y')), &tx);
1410 assert_eq!(app.screen, Screen::HostList);
1411 assert_eq!(app.keys.push().expected_count, 0);
1412 }
1413
1414 #[test]
1415 fn trust_accepted_authorizes_the_retry_to_record_the_host_key() {
1416 let (mut app, _scratch) = make_app();
1421 app.screen = Screen::ConfirmHostKeyTrust {
1422 alias: "h1".into(),
1423 hostname: "db.example.com".into(),
1424 retry: crate::app::PendingRetry::KeyPush {
1425 key_path: app.keys.list()[0].display_path.clone(),
1426 alias: "h1".into(),
1427 },
1428 };
1429 let (tx, _rx) = mpsc::channel();
1430 handle_host_key_trust_key(&mut app, k(KeyCode::Char('y')), &tx);
1431 assert_eq!(app.keys.push().expected_count, 1, "the retry ran");
1432 assert!(
1433 app.keys.push().trust_new_host_key,
1434 "the retry must be allowed to record the host key"
1435 );
1436 let auth = push_auth_for(&app, "h1", app.keys.push().trust_new_host_key);
1438 assert!(auth.trust_new_host_key);
1439 app.keys.push_mut().cancel_run();
1440 }
1441
1442 #[test]
1443 fn a_declined_trust_leaves_an_ordinary_push_unauthorized() {
1444 let mut app = trust_app();
1445 let (tx, _rx) = mpsc::channel();
1446 handle_host_key_trust_key(&mut app, k(KeyCode::Char('n')), &tx);
1447 assert!(!app.keys.push().trust_new_host_key);
1448 }
1449
1450 #[test]
1451 fn push_auth_collects_every_input_the_worker_cannot_reach() {
1452 let scratch = tempfile::tempdir().expect("tempdir").keep();
1453 let config = SshConfigFile {
1454 elements: SshConfigFile::parse_content(
1455 "Host h1\n HostName 1.1.1.1\n # purple:askpass keychain\n",
1456 ),
1457 path: scratch.join("cfg"),
1458 crlf: false,
1459 bom: false,
1460 };
1461 let mut app = App::new(config);
1462 app.bw_session = Some("tok".into());
1463 app.session_passwords
1464 .insert("h1".to_string(), "hunter2".to_string());
1465 let auth = push_auth_for(&app, "h1", false);
1466 assert_eq!(auth.askpass.as_deref(), Some("keychain"));
1467 assert_eq!(auth.session_password.as_deref(), Some("hunter2"));
1468 assert_eq!(auth.bw_session.as_deref(), Some("tok"));
1469 assert!(!auth.trust_new_host_key);
1470 let bare = push_auth_for(&app, "ghost", false);
1472 assert!(bare.askpass.is_none());
1473 assert!(bare.session_password.is_none());
1474 }
1475
1476 #[test]
1477 fn trust_declined_warns_and_leaves_the_dialog() {
1478 let mut app = trust_app();
1479 let (tx, _rx) = mpsc::channel();
1480 handle_host_key_trust_key(&mut app, k(KeyCode::Char('n')), &tx);
1481 assert_eq!(app.screen, Screen::HostList);
1482 let toast = app.status_center.toast().expect("toast");
1483 assert!(toast.text.contains("h1"), "got: {}", toast.text);
1484 }
1485
1486 #[test]
1487 fn trust_esc_declines_like_n() {
1488 let mut app = trust_app();
1489 let (tx, _rx) = mpsc::channel();
1490 handle_host_key_trust_key(&mut app, k(KeyCode::Esc), &tx);
1491 assert_eq!(app.screen, Screen::HostList);
1492 assert!(app.status_center.toast().is_some());
1493 }
1494
1495 #[test]
1496 fn trust_ignores_every_other_key() {
1497 for code in [
1500 KeyCode::Char('t'),
1501 KeyCode::Char('u'),
1502 KeyCode::Enter,
1503 KeyCode::Tab,
1504 KeyCode::Char(' '),
1505 ] {
1506 let mut app = trust_app();
1507 let (tx, _rx) = mpsc::channel();
1508 handle_host_key_trust_key(&mut app, k(code), &tx);
1509 assert!(
1510 matches!(app.screen, Screen::ConfirmHostKeyTrust { .. }),
1511 "{code:?} must leave the dialog open"
1512 );
1513 assert!(app.status_center.toast().is_none(), "{code:?}");
1514 }
1515 }
1516
1517 #[test]
1518 fn trust_declined_hands_the_next_waiting_host_its_dialog() {
1519 let mut app = trust_app();
1520 seed_key(&mut app);
1521 app.keys.push_mut().key_path = "~/.ssh/id_test".to_string();
1522 app.keys
1523 .push_mut()
1524 .pending_prompts
1525 .push_back(crate::app::KeyPushPrompt::Password {
1526 alias: "h1".into(),
1527 key_path: "~/.ssh/id_test".into(),
1528 });
1529 let (tx, _rx) = mpsc::channel();
1530 handle_host_key_trust_key(&mut app, k(KeyCode::Char('n')), &tx);
1531 assert_eq!(app.screen, Screen::PasswordPrompt);
1532 }
1533
1534 #[test]
1535 fn run_snippet_confirm_no_returns_to_picker() {
1536 let scratch = tempfile::tempdir().expect("tempdir").keep();
1537 let config = SshConfigFile {
1538 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1539 path: scratch.join("cfg"),
1540 crlf: false,
1541 bom: false,
1542 };
1543 let mut app = App::new(config);
1544 app.set_screen(Screen::ConfirmRunSnippet);
1545 let (tx, _rx) = mpsc::channel();
1546 handle_run_snippet_confirm_key(
1547 &mut app,
1548 KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
1549 &tx,
1550 );
1551 assert!(matches!(app.screen, Screen::SnippetHostPicker));
1552 }
1553
1554 #[test]
1555 fn run_snippet_confirm_yes_with_params_opens_param_form() {
1556 let scratch = tempfile::tempdir().expect("tempdir").keep();
1557 let config = SshConfigFile {
1558 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1559 path: scratch.join("cfg"),
1560 crlf: false,
1561 bom: false,
1562 };
1563 let mut app = App::new(config);
1564 app.snippets.set_flow_snippet(Some(crate::snippet::Snippet {
1566 name: "deploy".into(),
1567 command: "echo {{msg}}".into(),
1568 description: String::new(),
1569 interactive: false,
1570 }));
1571 app.snippets.set_flow_targets(vec!["h1".into()]);
1572 app.set_screen(Screen::ConfirmRunSnippet);
1573 let (tx, _rx) = mpsc::channel();
1574 handle_run_snippet_confirm_key(
1575 &mut app,
1576 KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
1577 &tx,
1578 );
1579 assert!(matches!(app.screen, Screen::SnippetParamForm));
1580 assert!(app.snippets.form_return_to_tab());
1581 }
1582}