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 let msg = if let Some(prov) = provider {
271 let display = crate::providers::provider_display_name(prov);
272 format!(
273 "Removed {} stale {} host{}.",
274 count,
275 display,
276 if count == 1 { "" } else { "s" }
277 )
278 } else {
279 format!(
280 "Removed {} stale host{}.",
281 count,
282 if count == 1 { "" } else { "s" }
283 )
284 };
285 app.notify(msg);
286}
287
288pub(super) fn handle_delete_key(app: &mut App, key: KeyEvent) {
296 let Screen::ConfirmDelete { alias } = &app.screen else {
297 return;
298 };
299 let alias = alias.clone();
300 match route_confirm_key(key) {
303 ConfirmAction::Yes => {
304 let siblings = app.hosts_state.ssh_config().siblings_of(&alias);
305
306 if !siblings.is_empty() {
307 app.hosts_state.ssh_config_mut().delete_host(&alias);
316 if let Err(e) = app.hosts_state.ssh_config().write() {
317 app.notify_error(crate::messages::failed_to_save(&e));
320 app.reload_hosts();
321 } else {
322 if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
323 let _ = tunnel.child.kill();
324 let _ = tunnel.child.wait();
325 }
326 app.update_last_modified();
327 app.reload_hosts();
328 app.notify(crate::messages::siblings_stripped(&alias, siblings.len()));
329 }
330 } else if let Some((element, position)) = app
331 .hosts_state
332 .ssh_config_mut()
333 .delete_host_undoable(&alias)
334 {
335 if let Err(e) = app.hosts_state.ssh_config().write() {
336 app.hosts_state
338 .ssh_config_mut()
339 .insert_host_at(element, position);
340 app.notify_error(crate::messages::failed_to_save(&e));
341 } else {
342 if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
344 let _ = tunnel.child.kill();
345 let _ = tunnel.child.wait();
346 }
347 let mut cert_cleanup_warning: Option<String> = None;
352 if !crate::demo_flag::is_demo() {
353 if let Ok(cert_path) =
354 crate::vault_ssh::cert_path_for(app.env().paths(), &alias)
355 {
356 match std::fs::remove_file(&cert_path) {
357 Ok(()) => {}
358 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
359 Err(e) => {
360 cert_cleanup_warning =
361 Some(crate::messages::cert_cleanup_warning(
362 &cert_path.display(),
363 &e,
364 ));
365 }
366 }
367 }
368 }
369 app.hosts_state
370 .undo_stack_mut()
371 .push(crate::app::DeletedHost { element, position });
372 if app.hosts_state.undo_stack().len() > 50 {
373 app.hosts_state.undo_stack_mut().remove(0);
374 }
375 app.update_last_modified();
376 app.reload_hosts();
377 if let Some(warning) = cert_cleanup_warning {
378 app.notify_error(warning);
379 } else {
380 app.notify(crate::messages::goodbye_host(&alias));
381 }
382 }
383 } else {
384 app.notify_warning(crate::messages::host_not_found(&alias));
385 }
386 app.set_screen(Screen::HostList);
387 }
388 ConfirmAction::No => {
389 app.set_screen(Screen::HostList);
390 }
391 ConfirmAction::Ignored => {}
392 }
393}
394
395pub(super) fn handle_vault_sign_key(
396 app: &mut App,
397 key: KeyEvent,
398 events_tx: &mpsc::Sender<AppEvent>,
399) {
400 if !matches!(app.screen, Screen::ConfirmVaultSign) {
407 return;
408 }
409 let route = route_confirm_key(key);
410 if route == ConfirmAction::Ignored {
411 return;
412 }
413 let Some(signable) = app.vault.take_pending_sign() else {
416 app.set_screen(Screen::HostList);
419 return;
420 };
421 let effects = {
422 let mut ctx = ConfirmCtx {
423 screen: &mut app.screen,
424 effects: Effects::default(),
425 };
426 match route {
427 ConfirmAction::Yes => {
428 ctx.set_screen(Screen::HostList);
434 let tx = events_tx.clone();
435 ctx.defer(move |app| start_vault_bulk_sign(app, signable, &tx));
436 }
437 ConfirmAction::No => {
438 ctx.set_screen(Screen::HostList);
439 }
440 ConfirmAction::Ignored => unreachable!(),
441 }
442 ctx.effects
443 };
444 effects.apply(app);
445}
446
447fn start_vault_bulk_sign(
450 app: &mut App,
451 signable: Vec<crate::vault_ssh::VaultSignTarget>,
452 events_tx: &mpsc::Sender<AppEvent>,
453) {
454 let total = signable.len();
455 if total == 0 {
456 return;
457 }
458 app.notify_progress(crate::messages::vault_signing_progress(
459 crate::animation::SPINNER_FRAMES[0],
460 0,
461 total,
462 "",
463 ));
464
465 let cancel = Arc::new(AtomicBool::new(false));
466 app.vault.set_signing_cancel(cancel.clone());
467
468 let in_flight = app.vault.sign_in_flight().clone();
469 let tx = events_tx.clone();
470 let env = std::sync::Arc::clone(&app.env);
473 let spawn_result = std::thread::Builder::new()
474 .name("vault-bulk-sign".into())
475 .spawn(move || {
476 let mut signed = 0u32;
477 let mut failed = 0u32;
478 let mut skipped = 0u32;
479 let mut consecutive_failures = 0usize;
480 let mut first_error: Option<String> = None;
481 let mut aborted_message: Option<String> = None;
482
483 for (idx, target) in signable.iter().enumerate() {
484 let crate::vault_ssh::VaultSignTarget {
485 alias,
486 role,
487 certificate_file: cert_file,
488 pubkey,
489 vault_addr,
490 } = target;
491 if cancel.load(Ordering::Relaxed) {
492 break;
493 }
494 let done = idx + 1;
495
496 {
499 let mut set = match in_flight.lock() {
504 Ok(g) => g,
505 Err(p) => p.into_inner(),
506 };
507 if !set.insert(alias.clone()) {
508 skipped += 1;
509 let _ = tx.send(AppEvent::VaultSignProgress {
510 alias: alias.clone(),
511 done,
512 total,
513 });
514 continue;
515 }
516 }
517
518 let _ = tx.send(AppEvent::VaultSignProgress {
519 alias: alias.clone(),
520 done,
521 total,
522 });
523
524 let cert_path =
525 match crate::vault_ssh::resolve_cert_path(env.paths(), alias, cert_file) {
526 Ok(p) => p,
527 Err(e) => {
528 failed += 1;
529 consecutive_failures += 1;
530 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
531 if first_error.is_none() {
532 first_error = Some(scrubbed);
533 }
534 remove_in_flight(&in_flight, alias);
535 if consecutive_failures >= 3 {
536 aborted_message = Some(crate::messages::vault_signing_aborted(
537 failed,
538 first_error.as_deref(),
539 ));
540 break;
541 }
542 continue;
543 }
544 };
545 let status = crate::vault_ssh::check_cert_validity(&env, &cert_path);
546 if !crate::vault_ssh::needs_renewal(&status) {
547 skipped += 1;
548 consecutive_failures = 0;
549 remove_in_flight(&in_flight, alias);
550 continue;
551 }
552
553 let sign_result = crate::vault_ssh::sign_certificate(
554 &env,
555 role,
556 pubkey,
557 alias,
558 vault_addr.as_deref(),
559 );
560 remove_in_flight(&in_flight, alias);
564 match sign_result {
565 Ok(_) => {
566 let _ = tx.send(AppEvent::VaultSignResult {
567 alias: alias.clone(),
568 certificate_file: cert_file.clone(),
569 success: true,
570 message: String::new(),
571 });
572 signed += 1;
573 consecutive_failures = 0;
574 }
575 Err(e) => {
576 let raw = e.to_string();
577 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
578 if first_error.is_none() {
579 first_error = Some(scrubbed.clone());
580 }
581 let _ = tx.send(AppEvent::VaultSignResult {
582 alias: alias.clone(),
583 certificate_file: cert_file.clone(),
584 success: false,
585 message: scrubbed,
586 });
587 failed += 1;
588 consecutive_failures += 1;
589 if consecutive_failures >= 3 {
590 aborted_message = Some(crate::messages::vault_signing_aborted(
591 failed,
592 first_error.as_deref(),
593 ));
594 break;
595 }
596 }
597 }
598 }
599
600 let cancelled = cancel.load(Ordering::Relaxed);
601 let _ = tx.send(AppEvent::VaultSignAllDone {
602 signed,
603 failed,
604 skipped,
605 cancelled,
606 aborted_message,
607 first_error,
608 });
609 });
610 match spawn_result {
611 Ok(handle) => {
612 log::info!("[purple] vault sign thread: spawned");
613 app.vault.set_sign_thread(handle);
614 }
615 Err(e) => {
616 log::warn!("[purple] vault sign thread: spawn failed: {}", e);
620 let _ = app.vault.finalize_signing_run();
621 app.notify_error(crate::messages::vault_spawn_failed(&e));
622 }
623 }
624}
625
626pub(super) fn remove_in_flight(
627 set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
628 alias: &str,
629) {
630 let mut guard = match set.lock() {
634 Ok(g) => g,
635 Err(p) => p.into_inner(),
636 };
637 guard.remove(alias);
638}
639
640pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
641 let mut ctx = HostKeyResetCtx {
642 ui: &mut app.ui,
643 status: &mut app.status_center,
644 screen: &mut app.screen,
645 demo_mode: app.demo_mode,
646 };
647 let Screen::ConfirmHostKeyReset {
648 alias,
649 hostname,
650 known_hosts_path,
651 askpass,
652 } = &*ctx.screen
653 else {
654 return;
655 };
656 let alias = alias.clone();
657 let hostname = hostname.clone();
658 let known_hosts_path = known_hosts_path.clone();
659 let askpass = askpass.clone();
660 match route_confirm_key(key) {
663 ConfirmAction::Yes => {
664 let output = std::process::Command::new("ssh-keygen")
665 .arg("-R")
666 .arg(&hostname)
667 .arg("-f")
668 .arg(&known_hosts_path)
669 .output();
670
671 match output {
672 Ok(result) if result.status.success() => {
673 ctx.notify(crate::messages::removed_host_key(&hostname));
674 if ctx.demo_mode {
675 ctx.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
676 } else {
677 ctx.ui.queue_connect(alias, askpass);
678 }
679 }
680 Ok(result) => {
681 let stderr = String::from_utf8_lossy(&result.stderr);
682 ctx.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
683 }
684 Err(e) => {
685 ctx.notify_error(crate::messages::ssh_keygen_failed(&e));
686 }
687 }
688 ctx.set_screen(Screen::HostList);
689 }
690 ConfirmAction::No => {
691 ctx.set_screen(Screen::HostList);
692 }
693 ConfirmAction::Ignored => {}
694 }
695}
696
697struct ContainerConfirm {
700 alias: String,
701 targets: Vec<(String, String)>,
702 action: crate::containers::ContainerAction,
703}
704
705fn apply_container_confirm(
709 ctx: &mut ContainerConfirmCtx,
710 key: KeyEvent,
711 confirm: ContainerConfirm,
712) {
713 match route_confirm_key(key) {
714 ConfirmAction::Yes => {
715 for (container_id, container_name) in confirm.targets {
716 queue_container_action(
717 ctx,
718 confirm.alias.clone(),
719 container_id,
720 container_name,
721 confirm.action,
722 );
723 }
724 ctx.set_screen(Screen::HostList);
725 }
726 ConfirmAction::No => {
727 ctx.set_screen(Screen::HostList);
728 }
729 ConfirmAction::Ignored => {}
730 }
731}
732
733fn container_confirm_ctx(app: &mut App) -> ContainerConfirmCtx<'_> {
735 ContainerConfirmCtx {
736 container_state: &mut app.container_state,
737 hosts: &app.hosts_state,
738 screen: &mut app.screen,
739 }
740}
741
742pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
744 let mut ctx = container_confirm_ctx(app);
745 let Screen::ConfirmContainerRestart {
746 alias,
747 container_id,
748 container_name,
749 ..
750 } = &*ctx.screen
751 else {
752 return;
753 };
754 let confirm = ContainerConfirm {
755 alias: alias.clone(),
756 targets: vec![(container_id.clone(), container_name.clone())],
757 action: crate::containers::ContainerAction::Restart,
758 };
759 apply_container_confirm(&mut ctx, key, confirm);
760}
761
762pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
764 let mut ctx = container_confirm_ctx(app);
765 let Screen::ConfirmContainerStop {
766 alias,
767 container_id,
768 container_name,
769 ..
770 } = &*ctx.screen
771 else {
772 return;
773 };
774 let confirm = ContainerConfirm {
775 alias: alias.clone(),
776 targets: vec![(container_id.clone(), container_name.clone())],
777 action: crate::containers::ContainerAction::Stop,
778 };
779 apply_container_confirm(&mut ctx, key, confirm);
780}
781
782pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
786 bulk_confirm_key(
787 app,
788 key,
789 Screen::ConfirmStackRestart,
790 crate::containers::ContainerAction::Restart,
791 );
792}
793
794pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
797 bulk_confirm_key(
798 app,
799 key,
800 Screen::ConfirmHostRestartAll,
801 crate::containers::ContainerAction::Restart,
802 );
803}
804
805pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
808 bulk_confirm_key(
809 app,
810 key,
811 Screen::ConfirmHostStopAll,
812 crate::containers::ContainerAction::Stop,
813 );
814}
815
816fn bulk_confirm_key(
822 app: &mut App,
823 key: KeyEvent,
824 expected_screen: Screen,
825 action: crate::containers::ContainerAction,
826) {
827 if app.screen != expected_screen {
828 return;
829 }
830 if route_confirm_key(key) == ConfirmAction::Ignored {
831 return;
832 }
833 let Some(payload) = app.containers_overview.take_pending_bulk_confirm() else {
834 app.set_screen(Screen::HostList);
838 return;
839 };
840 let targets: Vec<(String, String)> = payload
841 .members
842 .iter()
843 .map(|m| (m.container_id.clone(), m.container_name.clone()))
844 .collect();
845 let confirm = ContainerConfirm {
846 alias: payload.alias,
847 targets,
848 action,
849 };
850 let mut ctx = container_confirm_ctx(app);
851 apply_container_confirm(&mut ctx, key, confirm);
852}
853
854fn queue_container_action(
855 ctx: &mut ContainerConfirmCtx,
856 alias: String,
857 container_id: String,
858 container_name: String,
859 action: crate::containers::ContainerAction,
860) {
861 let Some(entry) = ctx.container_state.cache_entry(&alias) else {
862 log::debug!(
863 "[purple] container_action: queue aborted, no cache for alias={}",
864 alias
865 );
866 return;
867 };
868 let runtime = entry.runtime;
869 let askpass = ctx
870 .hosts
871 .list()
872 .iter()
873 .find(|h| h.alias == alias)
874 .and_then(|h| h.askpass.clone());
875 log::info!(
876 "[purple] container_action queued: alias={} id={} action={:?}",
877 alias,
878 container_id,
879 action
880 );
881 ctx.container_state
882 .queue_action(crate::app::ContainerActionRequest {
883 alias,
884 askpass,
885 runtime,
886 container_id,
887 container_name,
888 action,
889 });
890}
891
892pub(super) fn handle_key_push_key(
896 app: &mut App,
897 key: KeyEvent,
898 events_tx: &mpsc::Sender<AppEvent>,
899) {
900 let effects = {
901 let mut ctx = KeyPushConfirmCtx {
902 keys: &mut app.keys,
903 screen: &mut app.screen,
904 effects: Effects::default(),
905 };
906 match route_confirm_key(key) {
907 ConfirmAction::Yes => {
908 let key_index = match &*ctx.screen {
909 Screen::ConfirmKeyPush { key_index } => *key_index,
910 _ => return,
911 };
912 let aliases = std::mem::take(&mut ctx.keys.push_mut().committed);
913 ctx.set_screen(Screen::HostList);
914 let tx = events_tx.clone();
919 ctx.defer(move |app| start_key_push(app, key_index, aliases, &tx));
920 }
921 ConfirmAction::No => {
922 let key_index = match &*ctx.screen {
925 Screen::ConfirmKeyPush { key_index } => *key_index,
926 _ => return,
927 };
928 ctx.keys.push_mut().committed.clear();
929 ctx.set_screen(Screen::KeyPushPicker { key_index });
930 }
931 ConfirmAction::Ignored => {}
932 }
933 ctx.effects
934 };
935 effects.apply(app);
936}
937
938fn start_key_push(
945 app: &mut App,
946 key_index: usize,
947 aliases: Vec<String>,
948 events_tx: &mpsc::Sender<AppEvent>,
949) {
950 if app.keys.push().expected_count > 0
956 || app
957 .keys
958 .push()
959 .worker
960 .as_ref()
961 .is_some_and(|h| !h.is_finished())
962 {
963 log::debug!(
964 "[purple] key_push: rejected second push, run already in progress ({} of {})",
965 app.keys.push().results.len(),
966 app.keys.push().expected_count
967 );
968 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
969 return;
970 }
971 if aliases.is_empty() {
972 log::debug!("[purple] key_push: rejected, no aliases committed");
973 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
974 return;
975 }
976 let Some(key_info) = app.keys.list().get(key_index).cloned() else {
977 return;
978 };
979 if key_info.is_certificate {
980 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
981 return;
982 }
983 let pub_path = crate::key_push::pubkey_path_for(app.env.paths(), &key_info.display_path);
984 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
985 Ok(s) => s,
986 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
987 log::warn!(
988 "[purple] key_push: pubkey too large path={} bytes={}",
989 pub_path.display(),
990 n
991 );
992 app.notify_error(crate::messages::key_push_pubkey_too_large(
993 &key_info.name,
994 n,
995 ));
996 return;
997 }
998 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
999 log::warn!(
1000 "[purple] key_push: pubkey not a regular file path={}",
1001 pub_path.display()
1002 );
1003 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
1004 return;
1005 }
1006 Err(_) => {
1007 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
1011 return;
1012 }
1013 };
1014 let pubkey = match crate::key_push::validate_pubkey(&raw) {
1015 Ok(s) => s,
1016 Err(err) => {
1017 let detail = match &err {
1018 crate::key_push::PubkeyValidationError::Empty => "file is empty",
1019 crate::key_push::PubkeyValidationError::MultiLine => {
1020 "must be a single line; multi-line input is rejected"
1021 }
1022 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
1023 "key algorithm not allowed for static push"
1024 }
1025 crate::key_push::PubkeyValidationError::MalformedBase64 => {
1026 "base64 key body did not parse"
1027 }
1028 _ => "unexpected format",
1029 };
1030 log::warn!(
1031 "[purple] key_push: invalid pubkey path={} err={:?}",
1032 pub_path.display(),
1033 err
1034 );
1035 app.notify_error(crate::messages::key_push_invalid_pubkey(
1036 &key_info.name,
1037 detail,
1038 ));
1039 return;
1040 }
1041 };
1042
1043 let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
1045
1046 app.notify_progress(crate::messages::key_push_in_progress(
1047 &key_info.name,
1048 aliases.len(),
1049 ));
1050
1051 let config_path = app.hosts_state.ssh_config().path.clone();
1052 let tx = events_tx.clone();
1053 let pubkey_payload = pubkey;
1054 let handle = std::thread::Builder::new()
1055 .name("key-push".into())
1056 .spawn(move || {
1057 for alias in aliases {
1058 if cancel.load(Ordering::Relaxed) {
1059 break;
1060 }
1061 let outcome =
1062 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
1063 let _ = tx.send(AppEvent::KeyPushResult {
1064 run_id,
1065 result: crate::key_push::KeyPushResult { alias, outcome },
1066 });
1067 }
1068 });
1069 match handle {
1070 Ok(h) => {
1071 app.keys.push_mut().worker = Some(h);
1072 }
1073 Err(e) => {
1074 log::error!("[purple] key_push: failed to spawn worker: {}", e);
1075 app.status_center.clear_sticky_status();
1079 app.notify_error(crate::messages::key_push_thread_spawn_failed());
1080 app.keys.push_mut().clear_inflight_state();
1081 }
1082 }
1083}
1084
1085#[cfg(test)]
1086mod key_push_confirm_tests {
1087 use super::*;
1094 use crate::ssh_config::model::SshConfigFile;
1095 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1096
1097 fn make_app() -> (App, std::path::PathBuf) {
1098 let scratch = tempfile::tempdir().expect("tempdir").keep();
1099 let config = SshConfigFile {
1100 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1101 path: scratch.join("test_config"),
1102 crlf: false,
1103 bom: false,
1104 };
1105 let mut app = App::new(config);
1106 let pub_path = scratch.join("id_test.pub");
1109 std::fs::write(
1110 &pub_path,
1111 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
1112 )
1113 .unwrap();
1114 app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1115 name: "id_test".into(),
1116 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
1117 key_type: "ED25519".into(),
1118 bits: "256".into(),
1119 fingerprint: String::new(),
1120 comment: "test@host".into(),
1121 linked_hosts: vec![],
1122 bishop_art: String::new(),
1123 strength_score: 95,
1124 encrypted: false,
1125 agent_loaded: false,
1126 is_certificate: false,
1127 mtime_ts: None,
1128 });
1129 (app, scratch)
1130 }
1131
1132 fn k(code: KeyCode) -> KeyEvent {
1133 KeyEvent::new(code, KeyModifiers::NONE)
1134 }
1135
1136 #[test]
1137 fn n_returns_to_picker_with_key_index_preserved() {
1138 let (mut app, _scratch) = make_app();
1139 app.keys.push_mut().committed = vec!["h1".into()];
1140 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1141 let (tx, _rx) = mpsc::channel();
1142 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
1143 match app.screen {
1144 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
1145 ref other => panic!("expected KeyPushPicker, got {:?}", other),
1146 }
1147 assert!(
1148 app.keys.push().committed.is_empty(),
1149 "n should drop the frozen selection"
1150 );
1151 }
1152
1153 #[test]
1154 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
1155 let (mut app, _scratch) = make_app();
1156 app.keys.push_mut().committed = vec!["h1".into()];
1157 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1158 let (tx, _rx) = mpsc::channel();
1159 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
1160 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
1161 }
1162
1163 #[test]
1164 fn start_rejects_when_a_previous_run_is_still_in_flight() {
1165 let (mut app, _scratch) = make_app();
1166 app.keys.push_mut().expected_count = 2;
1167 app.keys
1168 .push_mut()
1169 .results
1170 .push(crate::key_push::KeyPushResult {
1171 alias: "h1".into(),
1172 outcome: crate::key_push::KeyPushOutcome::Appended,
1173 });
1174 let (tx, _rx) = mpsc::channel();
1175 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1176 assert_eq!(
1177 app.keys.push().expected_count,
1178 2,
1179 "guard must not reset in-flight state"
1180 );
1181 let toast = app.status_center.toast().expect("toast set");
1182 assert!(
1183 toast.text.contains("already running"),
1184 "expected 'already running' warning, got: {}",
1185 toast.text
1186 );
1187 }
1188
1189 #[test]
1190 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1191 let (mut app, _scratch) = make_app();
1192 let (tx, _rx) = mpsc::channel();
1193 start_key_push(&mut app, 0, Vec::new(), &tx);
1194 assert_eq!(app.keys.push().expected_count, 0);
1195 assert!(app.keys.push().worker.is_none());
1196 let toast = app.status_center.toast().expect("toast set");
1197 assert!(toast.is_error());
1198 }
1199
1200 #[test]
1201 fn start_rejects_certificate_key() {
1202 let (mut app, _scratch) = make_app();
1203 app.keys.list_mut()[0].is_certificate = true;
1204 let (tx, _rx) = mpsc::channel();
1205 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1206 assert_eq!(app.keys.push().expected_count, 0);
1207 assert!(app.keys.push().worker.is_none());
1208 let toast = app.status_center.toast().expect("toast set");
1209 assert!(toast.is_error());
1210 assert!(toast.text.contains("Certificates"));
1211 }
1212
1213 #[test]
1214 fn start_rejects_missing_pubkey_file() {
1215 let (mut app, _scratch) = make_app();
1216 app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1217 let (tx, _rx) = mpsc::channel();
1218 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1219 assert_eq!(app.keys.push().expected_count, 0);
1220 let toast = app.status_center.toast().expect("toast set");
1221 assert!(toast.is_error());
1222 }
1223
1224 #[test]
1225 fn start_rejects_invalid_pubkey_content() {
1226 let (mut app, scratch) = make_app();
1227 let pub_path = scratch.join("id_bad.pub");
1230 std::fs::write(
1231 &pub_path,
1232 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1233 )
1234 .unwrap();
1235 app.keys.list_mut()[0].display_path =
1236 pub_path.with_extension("").to_string_lossy().into_owned();
1237 app.keys.list_mut()[0].name = "id_bad".into();
1238 let (tx, _rx) = mpsc::channel();
1239 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1240 assert_eq!(app.keys.push().expected_count, 0);
1241 assert!(app.keys.push().worker.is_none());
1242 let toast = app.status_center.toast().expect("toast set");
1243 assert!(toast.is_error());
1244 assert!(toast.text.contains("validation"));
1245 }
1246}