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 if 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 =
372 Some(crate::messages::cert_cleanup_warning(
373 &cert_path.display(),
374 &e,
375 ));
376 }
377 }
378 }
379 }
380 app.hosts_state
381 .undo_stack_mut()
382 .push(crate::app::DeletedHost { element, position });
383 if app.hosts_state.undo_stack().len() > 50 {
384 app.hosts_state.undo_stack_mut().remove(0);
385 }
386 app.update_last_modified();
387 app.reload_hosts();
388 log::debug!("[purple] host deleted: alias={alias} (undoable)");
389 if let Some(warning) = cert_cleanup_warning {
390 app.notify_error(warning);
391 } else {
392 app.notify(crate::messages::goodbye_host(&alias));
393 }
394 }
395 } else {
396 app.notify_warning(crate::messages::host_not_found(&alias));
397 }
398 app.set_screen(Screen::HostList);
399 }
400 ConfirmAction::No => {
401 app.set_screen(Screen::HostList);
402 }
403 ConfirmAction::Ignored => {}
404 }
405}
406
407pub(super) fn handle_vault_sign_key(
408 app: &mut App,
409 key: KeyEvent,
410 events_tx: &mpsc::Sender<AppEvent>,
411) {
412 if !matches!(app.screen, Screen::ConfirmVaultSign) {
419 return;
420 }
421 let route = route_confirm_key(key);
422 if route == ConfirmAction::Ignored {
423 return;
424 }
425 let Some(signable) = app.vault.take_pending_sign() else {
428 app.set_screen(Screen::HostList);
431 return;
432 };
433 let effects = {
434 let mut ctx = ConfirmCtx {
435 screen: &mut app.screen,
436 effects: Effects::default(),
437 };
438 match route {
439 ConfirmAction::Yes => {
440 ctx.set_screen(Screen::HostList);
446 let tx = events_tx.clone();
447 ctx.defer(move |app| start_vault_bulk_sign(app, signable, &tx));
448 }
449 ConfirmAction::No => {
450 ctx.set_screen(Screen::HostList);
451 }
452 ConfirmAction::Ignored => unreachable!(),
453 }
454 ctx.effects
455 };
456 effects.apply(app);
457}
458
459fn start_vault_bulk_sign(
462 app: &mut App,
463 signable: Vec<crate::vault_ssh::VaultSignTarget>,
464 events_tx: &mpsc::Sender<AppEvent>,
465) {
466 let total = signable.len();
467 if total == 0 {
468 return;
469 }
470 app.notify_progress(crate::messages::vault_signing_progress(
471 crate::animation::SPINNER_FRAMES[0],
472 0,
473 total,
474 "",
475 ));
476
477 let cancel = Arc::new(AtomicBool::new(false));
478 app.vault.set_signing_cancel(cancel.clone());
479
480 let in_flight = app.vault.sign_in_flight().clone();
481 let tx = events_tx.clone();
482 let env = std::sync::Arc::clone(&app.env);
485 let spawn_result = std::thread::Builder::new()
486 .name("vault-bulk-sign".into())
487 .spawn(move || {
488 let mut signed = 0u32;
489 let mut failed = 0u32;
490 let mut skipped = 0u32;
491 let mut consecutive_failures = 0usize;
492 let mut first_error: Option<String> = None;
493 let mut aborted_message: Option<String> = None;
494
495 for (idx, target) in signable.iter().enumerate() {
496 let crate::vault_ssh::VaultSignTarget {
497 alias,
498 role,
499 certificate_file: cert_file,
500 pubkey,
501 vault_addr,
502 } = target;
503 if cancel.load(Ordering::Relaxed) {
504 break;
505 }
506 let done = idx + 1;
507
508 {
511 let mut set = match in_flight.lock() {
516 Ok(g) => g,
517 Err(p) => p.into_inner(),
518 };
519 if !set.insert(alias.clone()) {
520 skipped += 1;
521 let _ = tx.send(AppEvent::VaultSignProgress {
522 alias: alias.clone(),
523 done,
524 total,
525 });
526 continue;
527 }
528 }
529
530 let _ = tx.send(AppEvent::VaultSignProgress {
531 alias: alias.clone(),
532 done,
533 total,
534 });
535
536 let cert_path =
537 match crate::vault_ssh::resolve_cert_path(env.paths(), alias, cert_file) {
538 Ok(p) => p,
539 Err(e) => {
540 failed += 1;
541 consecutive_failures += 1;
542 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
543 if first_error.is_none() {
544 first_error = Some(scrubbed);
545 }
546 remove_in_flight(&in_flight, alias);
547 if consecutive_failures >= 3 {
548 aborted_message = Some(crate::messages::vault_signing_aborted(
549 failed,
550 first_error.as_deref(),
551 ));
552 break;
553 }
554 continue;
555 }
556 };
557 let status = crate::vault_ssh::check_cert_validity(&env, &cert_path);
558 if !crate::vault_ssh::needs_renewal(&status) {
559 skipped += 1;
560 consecutive_failures = 0;
561 remove_in_flight(&in_flight, alias);
562 continue;
563 }
564
565 let sign_result = crate::vault_ssh::sign_certificate(
566 &env,
567 role,
568 pubkey,
569 alias,
570 vault_addr.as_deref(),
571 );
572 remove_in_flight(&in_flight, alias);
576 match sign_result {
577 Ok(_) => {
578 let _ = tx.send(AppEvent::VaultSignResult {
579 alias: alias.clone(),
580 certificate_file: cert_file.clone(),
581 success: true,
582 message: String::new(),
583 });
584 signed += 1;
585 consecutive_failures = 0;
586 }
587 Err(e) => {
588 let raw = e.to_string();
589 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
590 if first_error.is_none() {
591 first_error = Some(scrubbed.clone());
592 }
593 let _ = tx.send(AppEvent::VaultSignResult {
594 alias: alias.clone(),
595 certificate_file: cert_file.clone(),
596 success: false,
597 message: scrubbed,
598 });
599 failed += 1;
600 consecutive_failures += 1;
601 if consecutive_failures >= 3 {
602 aborted_message = Some(crate::messages::vault_signing_aborted(
603 failed,
604 first_error.as_deref(),
605 ));
606 break;
607 }
608 }
609 }
610 }
611
612 let cancelled = cancel.load(Ordering::Relaxed);
613 let _ = tx.send(AppEvent::VaultSignAllDone {
614 signed,
615 failed,
616 skipped,
617 cancelled,
618 aborted_message,
619 first_error,
620 });
621 });
622 match spawn_result {
623 Ok(handle) => {
624 log::info!("[purple] vault sign thread: spawned");
625 app.vault.set_sign_thread(handle);
626 }
627 Err(e) => {
628 log::warn!("[purple] vault sign thread: spawn failed: {}", e);
632 let _ = app.vault.finalize_signing_run();
633 app.notify_error(crate::messages::vault_spawn_failed(&e));
634 }
635 }
636}
637
638pub(super) fn remove_in_flight(
639 set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
640 alias: &str,
641) {
642 let mut guard = match set.lock() {
646 Ok(g) => g,
647 Err(p) => p.into_inner(),
648 };
649 guard.remove(alias);
650}
651
652pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
653 let mut ctx = HostKeyResetCtx {
654 ui: &mut app.ui,
655 status: &mut app.status_center,
656 screen: &mut app.screen,
657 demo_mode: app.demo_mode,
658 };
659 let Screen::ConfirmHostKeyReset {
660 alias,
661 hostname,
662 known_hosts_path,
663 askpass,
664 } = &*ctx.screen
665 else {
666 return;
667 };
668 let alias = alias.clone();
669 let hostname = hostname.clone();
670 let known_hosts_path = known_hosts_path.clone();
671 let askpass = askpass.clone();
672 match route_confirm_key(key) {
675 ConfirmAction::Yes => {
676 let output = std::process::Command::new("ssh-keygen")
677 .arg("-R")
678 .arg(&hostname)
679 .arg("-f")
680 .arg(&known_hosts_path)
681 .output();
682
683 match output {
684 Ok(result) if result.status.success() => {
685 ctx.notify(crate::messages::removed_host_key(&hostname));
686 if ctx.demo_mode {
687 ctx.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
688 } else {
689 ctx.ui.queue_connect(alias, askpass);
690 }
691 }
692 Ok(result) => {
693 let stderr = String::from_utf8_lossy(&result.stderr);
694 ctx.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
695 }
696 Err(e) => {
697 ctx.notify_error(crate::messages::ssh_keygen_failed(&e));
698 }
699 }
700 ctx.set_screen(Screen::HostList);
701 }
702 ConfirmAction::No => {
703 ctx.set_screen(Screen::HostList);
704 }
705 ConfirmAction::Ignored => {}
706 }
707}
708
709struct ContainerConfirm {
712 alias: String,
713 targets: Vec<(String, String)>,
714 action: crate::containers::ContainerAction,
715}
716
717fn apply_container_confirm(
721 ctx: &mut ContainerConfirmCtx,
722 key: KeyEvent,
723 confirm: ContainerConfirm,
724) {
725 match route_confirm_key(key) {
726 ConfirmAction::Yes => {
727 for (container_id, container_name) in confirm.targets {
728 queue_container_action(
729 ctx,
730 confirm.alias.clone(),
731 container_id,
732 container_name,
733 confirm.action,
734 );
735 }
736 ctx.set_screen(Screen::HostList);
737 }
738 ConfirmAction::No => {
739 ctx.set_screen(Screen::HostList);
740 }
741 ConfirmAction::Ignored => {}
742 }
743}
744
745fn container_confirm_ctx(app: &mut App) -> ContainerConfirmCtx<'_> {
747 ContainerConfirmCtx {
748 container_state: &mut app.container_state,
749 hosts: &app.hosts_state,
750 screen: &mut app.screen,
751 }
752}
753
754pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
756 let mut ctx = container_confirm_ctx(app);
757 let Screen::ConfirmContainerRestart {
758 alias,
759 container_id,
760 container_name,
761 ..
762 } = &*ctx.screen
763 else {
764 return;
765 };
766 let confirm = ContainerConfirm {
767 alias: alias.clone(),
768 targets: vec![(container_id.clone(), container_name.clone())],
769 action: crate::containers::ContainerAction::Restart,
770 };
771 apply_container_confirm(&mut ctx, key, confirm);
772}
773
774pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
776 let mut ctx = container_confirm_ctx(app);
777 let Screen::ConfirmContainerStop {
778 alias,
779 container_id,
780 container_name,
781 ..
782 } = &*ctx.screen
783 else {
784 return;
785 };
786 let confirm = ContainerConfirm {
787 alias: alias.clone(),
788 targets: vec![(container_id.clone(), container_name.clone())],
789 action: crate::containers::ContainerAction::Stop,
790 };
791 apply_container_confirm(&mut ctx, key, confirm);
792}
793
794pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
798 bulk_confirm_key(
799 app,
800 key,
801 Screen::ConfirmStackRestart,
802 crate::containers::ContainerAction::Restart,
803 );
804}
805
806pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
809 bulk_confirm_key(
810 app,
811 key,
812 Screen::ConfirmHostRestartAll,
813 crate::containers::ContainerAction::Restart,
814 );
815}
816
817pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
820 bulk_confirm_key(
821 app,
822 key,
823 Screen::ConfirmHostStopAll,
824 crate::containers::ContainerAction::Stop,
825 );
826}
827
828fn bulk_confirm_key(
834 app: &mut App,
835 key: KeyEvent,
836 expected_screen: Screen,
837 action: crate::containers::ContainerAction,
838) {
839 if app.screen != expected_screen {
840 return;
841 }
842 if route_confirm_key(key) == ConfirmAction::Ignored {
843 return;
844 }
845 let Some(payload) = app.containers_overview.take_pending_bulk_confirm() else {
846 app.set_screen(Screen::HostList);
850 return;
851 };
852 let targets: Vec<(String, String)> = payload
853 .members
854 .iter()
855 .map(|m| (m.container_id.clone(), m.container_name.clone()))
856 .collect();
857 let confirm = ContainerConfirm {
858 alias: payload.alias,
859 targets,
860 action,
861 };
862 let mut ctx = container_confirm_ctx(app);
863 apply_container_confirm(&mut ctx, key, confirm);
864}
865
866fn queue_container_action(
867 ctx: &mut ContainerConfirmCtx,
868 alias: String,
869 container_id: String,
870 container_name: String,
871 action: crate::containers::ContainerAction,
872) {
873 let Some(entry) = ctx.container_state.cache_entry(&alias) else {
874 log::debug!(
875 "[purple] container_action: queue aborted, no cache for alias={}",
876 alias
877 );
878 return;
879 };
880 let runtime = entry.runtime;
881 let askpass = ctx
882 .hosts
883 .list()
884 .iter()
885 .find(|h| h.alias == alias)
886 .and_then(|h| h.askpass.clone());
887 log::info!(
888 "[purple] container_action queued: alias={} id={} action={:?}",
889 alias,
890 container_id,
891 action
892 );
893 ctx.container_state
894 .queue_action(crate::app::ContainerActionRequest {
895 alias,
896 askpass,
897 runtime,
898 container_id,
899 container_name,
900 action,
901 });
902}
903
904pub(super) fn handle_key_push_key(
908 app: &mut App,
909 key: KeyEvent,
910 events_tx: &mpsc::Sender<AppEvent>,
911) {
912 let effects = {
913 let mut ctx = KeyPushConfirmCtx {
914 keys: &mut app.keys,
915 screen: &mut app.screen,
916 effects: Effects::default(),
917 };
918 match route_confirm_key(key) {
919 ConfirmAction::Yes => {
920 let key_index = match &*ctx.screen {
921 Screen::ConfirmKeyPush { key_index } => *key_index,
922 _ => return,
923 };
924 let aliases = std::mem::take(&mut ctx.keys.push_mut().committed);
925 ctx.set_screen(Screen::HostList);
926 let tx = events_tx.clone();
931 ctx.defer(move |app| start_key_push(app, key_index, aliases, &tx));
932 }
933 ConfirmAction::No => {
934 let key_index = match &*ctx.screen {
937 Screen::ConfirmKeyPush { key_index } => *key_index,
938 _ => return,
939 };
940 ctx.keys.push_mut().committed.clear();
941 ctx.set_screen(Screen::KeyPushPicker { key_index });
942 }
943 ConfirmAction::Ignored => {}
944 }
945 ctx.effects
946 };
947 effects.apply(app);
948}
949
950pub(super) fn handle_run_snippet_confirm_key(
955 app: &mut App,
956 key: KeyEvent,
957 events_tx: &mpsc::Sender<AppEvent>,
958) {
959 match route_confirm_key(key) {
960 ConfirmAction::Yes => {
961 super::snippet::run_flow_snippet(app, events_tx);
962 }
963 ConfirmAction::No => {
964 app.set_screen(Screen::SnippetHostPicker);
965 }
966 ConfirmAction::Ignored => {}
967 }
968}
969
970fn start_key_push(
977 app: &mut App,
978 key_index: usize,
979 aliases: Vec<String>,
980 events_tx: &mpsc::Sender<AppEvent>,
981) {
982 if app.keys.push().expected_count > 0
988 || app
989 .keys
990 .push()
991 .worker
992 .as_ref()
993 .is_some_and(|h| !h.is_finished())
994 {
995 log::debug!(
996 "[purple] key_push: rejected second push, run already in progress ({} of {})",
997 app.keys.push().results.len(),
998 app.keys.push().expected_count
999 );
1000 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
1001 return;
1002 }
1003 if aliases.is_empty() {
1004 log::debug!("[purple] key_push: rejected, no aliases committed");
1005 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
1006 return;
1007 }
1008 let Some(key_info) = app.keys.list().get(key_index).cloned() else {
1009 return;
1010 };
1011 if key_info.is_certificate {
1012 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
1013 return;
1014 }
1015 let pub_path = crate::key_push::pubkey_path_for(app.env.paths(), &key_info.display_path);
1016 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
1017 Ok(s) => s,
1018 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
1019 log::warn!(
1020 "[config] key_push: pubkey too large path={} bytes={}",
1021 pub_path.display(),
1022 n
1023 );
1024 app.notify_error(crate::messages::key_push_pubkey_too_large(
1025 &key_info.name,
1026 n,
1027 ));
1028 return;
1029 }
1030 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
1031 log::warn!(
1032 "[config] key_push: pubkey not a regular file path={}",
1033 pub_path.display()
1034 );
1035 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
1036 return;
1037 }
1038 Err(_) => {
1039 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
1043 return;
1044 }
1045 };
1046 let pubkey = match crate::key_push::validate_pubkey(&raw) {
1047 Ok(s) => s,
1048 Err(err) => {
1049 let detail = match &err {
1050 crate::key_push::PubkeyValidationError::Empty => "file is empty",
1051 crate::key_push::PubkeyValidationError::MultiLine => {
1052 "must be a single line; multi-line input is rejected"
1053 }
1054 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
1055 "key algorithm not allowed for static push"
1056 }
1057 crate::key_push::PubkeyValidationError::MalformedBase64 => {
1058 "base64 key body did not parse"
1059 }
1060 _ => "unexpected format",
1061 };
1062 log::warn!(
1063 "[config] key_push: invalid pubkey path={} err={:?}",
1064 pub_path.display(),
1065 err
1066 );
1067 app.notify_error(crate::messages::key_push_invalid_pubkey(
1068 &key_info.name,
1069 detail,
1070 ));
1071 return;
1072 }
1073 };
1074
1075 let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
1077
1078 app.notify_progress(crate::messages::key_push_in_progress(
1079 &key_info.name,
1080 aliases.len(),
1081 ));
1082
1083 let config_path = app.hosts_state.ssh_config().path.clone();
1084 let tx = events_tx.clone();
1085 let pubkey_payload = pubkey;
1086 let handle = std::thread::Builder::new()
1087 .name("key-push".into())
1088 .spawn(move || {
1089 for alias in aliases {
1090 if cancel.load(Ordering::Relaxed) {
1091 break;
1092 }
1093 let outcome =
1094 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
1095 let _ = tx.send(AppEvent::KeyPushResult {
1096 run_id,
1097 result: crate::key_push::KeyPushResult { alias, outcome },
1098 });
1099 }
1100 });
1101 match handle {
1102 Ok(h) => {
1103 app.keys.push_mut().worker = Some(h);
1104 }
1105 Err(e) => {
1106 log::error!("[purple] key_push: failed to spawn worker: {}", e);
1107 app.status_center.clear_sticky_status();
1111 app.notify_error(crate::messages::key_push_thread_spawn_failed());
1112 app.keys.push_mut().clear_inflight_state();
1113 }
1114 }
1115}
1116
1117#[cfg(test)]
1118mod key_push_confirm_tests {
1119 use super::*;
1126 use crate::ssh_config::model::SshConfigFile;
1127 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1128
1129 fn make_app() -> (App, std::path::PathBuf) {
1130 let scratch = tempfile::tempdir().expect("tempdir").keep();
1131 let config = SshConfigFile {
1132 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1133 path: scratch.join("test_config"),
1134 crlf: false,
1135 bom: false,
1136 };
1137 let mut app = App::new(config);
1138 let pub_path = scratch.join("id_test.pub");
1141 std::fs::write(
1142 &pub_path,
1143 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
1144 )
1145 .unwrap();
1146 app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1147 name: "id_test".into(),
1148 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
1149 key_type: "ED25519".into(),
1150 bits: "256".into(),
1151 fingerprint: String::new(),
1152 comment: "test@host".into(),
1153 linked_hosts: vec![],
1154 bishop_art: String::new(),
1155 strength_score: 95,
1156 encrypted: false,
1157 agent_loaded: false,
1158 is_certificate: false,
1159 mtime_ts: None,
1160 });
1161 (app, scratch)
1162 }
1163
1164 fn k(code: KeyCode) -> KeyEvent {
1165 KeyEvent::new(code, KeyModifiers::NONE)
1166 }
1167
1168 #[test]
1169 fn n_returns_to_picker_with_key_index_preserved() {
1170 let (mut app, _scratch) = make_app();
1171 app.keys.push_mut().committed = vec!["h1".into()];
1172 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1173 let (tx, _rx) = mpsc::channel();
1174 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
1175 match app.screen {
1176 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
1177 ref other => panic!("expected KeyPushPicker, got {:?}", other),
1178 }
1179 assert!(
1180 app.keys.push().committed.is_empty(),
1181 "n should drop the frozen selection"
1182 );
1183 }
1184
1185 #[test]
1186 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
1187 let (mut app, _scratch) = make_app();
1188 app.keys.push_mut().committed = vec!["h1".into()];
1189 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1190 let (tx, _rx) = mpsc::channel();
1191 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
1192 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
1193 }
1194
1195 #[test]
1196 fn start_rejects_when_a_previous_run_is_still_in_flight() {
1197 let (mut app, _scratch) = make_app();
1198 app.keys.push_mut().expected_count = 2;
1199 app.keys
1200 .push_mut()
1201 .results
1202 .push(crate::key_push::KeyPushResult {
1203 alias: "h1".into(),
1204 outcome: crate::key_push::KeyPushOutcome::Appended,
1205 });
1206 let (tx, _rx) = mpsc::channel();
1207 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1208 assert_eq!(
1209 app.keys.push().expected_count,
1210 2,
1211 "guard must not reset in-flight state"
1212 );
1213 let toast = app.status_center.toast().expect("toast set");
1214 assert!(
1215 toast.text.contains("already running"),
1216 "expected 'already running' warning, got: {}",
1217 toast.text
1218 );
1219 }
1220
1221 #[test]
1222 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1223 let (mut app, _scratch) = make_app();
1224 let (tx, _rx) = mpsc::channel();
1225 start_key_push(&mut app, 0, Vec::new(), &tx);
1226 assert_eq!(app.keys.push().expected_count, 0);
1227 assert!(app.keys.push().worker.is_none());
1228 let toast = app.status_center.toast().expect("toast set");
1229 assert!(toast.is_error());
1230 }
1231
1232 #[test]
1233 fn start_rejects_certificate_key() {
1234 let (mut app, _scratch) = make_app();
1235 app.keys.list_mut()[0].is_certificate = true;
1236 let (tx, _rx) = mpsc::channel();
1237 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1238 assert_eq!(app.keys.push().expected_count, 0);
1239 assert!(app.keys.push().worker.is_none());
1240 let toast = app.status_center.toast().expect("toast set");
1241 assert!(toast.is_error());
1242 assert!(toast.text.contains("Certificates"));
1243 }
1244
1245 #[test]
1246 fn start_rejects_missing_pubkey_file() {
1247 let (mut app, _scratch) = make_app();
1248 app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1249 let (tx, _rx) = mpsc::channel();
1250 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1251 assert_eq!(app.keys.push().expected_count, 0);
1252 let toast = app.status_center.toast().expect("toast set");
1253 assert!(toast.is_error());
1254 }
1255
1256 #[test]
1257 fn start_rejects_invalid_pubkey_content() {
1258 let (mut app, scratch) = make_app();
1259 let pub_path = scratch.join("id_bad.pub");
1262 std::fs::write(
1263 &pub_path,
1264 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1265 )
1266 .unwrap();
1267 app.keys.list_mut()[0].display_path =
1268 pub_path.with_extension("").to_string_lossy().into_owned();
1269 app.keys.list_mut()[0].name = "id_bad".into();
1270 let (tx, _rx) = mpsc::channel();
1271 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1272 assert_eq!(app.keys.push().expected_count, 0);
1273 assert!(app.keys.push().worker.is_none());
1274 let toast = app.status_center.toast().expect("toast set");
1275 assert!(toast.is_error());
1276 assert!(toast.text.contains("validation"));
1277 }
1278
1279 #[test]
1280 fn run_snippet_confirm_no_returns_to_picker() {
1281 let scratch = tempfile::tempdir().expect("tempdir").keep();
1282 let config = SshConfigFile {
1283 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1284 path: scratch.join("cfg"),
1285 crlf: false,
1286 bom: false,
1287 };
1288 let mut app = App::new(config);
1289 app.set_screen(Screen::ConfirmRunSnippet);
1290 let (tx, _rx) = mpsc::channel();
1291 handle_run_snippet_confirm_key(
1292 &mut app,
1293 KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
1294 &tx,
1295 );
1296 assert!(matches!(app.screen, Screen::SnippetHostPicker));
1297 }
1298
1299 #[test]
1300 fn run_snippet_confirm_yes_with_params_opens_param_form() {
1301 let scratch = tempfile::tempdir().expect("tempdir").keep();
1302 let config = SshConfigFile {
1303 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1304 path: scratch.join("cfg"),
1305 crlf: false,
1306 bom: false,
1307 };
1308 let mut app = App::new(config);
1309 app.snippets.set_flow_snippet(Some(crate::snippet::Snippet {
1311 name: "deploy".into(),
1312 command: "echo {{msg}}".into(),
1313 description: String::new(),
1314 }));
1315 app.snippets.set_flow_targets(vec!["h1".into()]);
1316 app.set_screen(Screen::ConfirmRunSnippet);
1317 let (tx, _rx) = mpsc::channel();
1318 handle_run_snippet_confirm_key(
1319 &mut app,
1320 KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
1321 &tx,
1322 );
1323 assert!(matches!(app.screen, Screen::SnippetParamForm));
1324 assert!(app.snippets.form_return_to_tab());
1325 }
1326}