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 let effects = {
182 let mut ctx = ConfirmCtx {
183 screen: &mut app.screen,
184 effects: Effects::default(),
185 };
186 let Screen::ConfirmPurgeStale { provider: p, .. } = &*ctx.screen else {
187 return;
188 };
189 let provider = p.clone();
190 let return_screen = if provider.is_some() {
191 Screen::Providers
192 } else {
193 Screen::HostList
194 };
195 match route_confirm_key(key) {
196 ConfirmAction::Yes => {
197 ctx.defer(move |app| execute_purge_stale(app, provider.as_deref()));
202 ctx.set_screen(return_screen);
203 }
204 ConfirmAction::No => {
205 ctx.set_screen(return_screen);
206 }
207 ConfirmAction::Ignored => {}
208 }
209 ctx.effects
210 };
211 effects.apply(app);
212}
213
214fn execute_purge_stale(app: &mut App, provider: Option<&str>) {
215 let stale = app.hosts_state.ssh_config().stale_hosts();
216 if stale.is_empty() {
217 return;
218 }
219 let targets: Vec<(String, u64)> = if let Some(prov) = provider {
221 stale
222 .into_iter()
223 .filter(|(alias, _)| {
224 app.hosts_state
225 .ssh_config()
226 .host_entries()
227 .iter()
228 .any(|e| e.alias == *alias && e.provider.as_deref() == Some(prov))
229 })
230 .collect()
231 } else {
232 stale
233 };
234 if targets.is_empty() {
235 return;
236 }
237 let config_backup = app.hosts_state.ssh_config().clone();
238 let count = targets.len();
239 for (alias, _) in &targets {
240 app.hosts_state.ssh_config_mut().delete_host(alias);
241 }
242 if let Err(e) = app.hosts_state.ssh_config().write() {
243 app.hosts_state.set_ssh_config(config_backup);
244 app.notify_error(crate::messages::failed_to_save(&e));
245 return;
246 }
247 for (alias, _) in &targets {
249 if let Some(mut tunnel) = app.tunnels.active_remove(alias) {
250 let _ = tunnel.child.kill();
251 let _ = tunnel.child.wait();
252 }
253 }
254 app.hosts_state.clear_undo();
255 app.update_last_modified();
256 app.reload_hosts();
257 let msg = if let Some(prov) = provider {
258 let display = crate::providers::provider_display_name(prov);
259 format!(
260 "Removed {} stale {} host{}.",
261 count,
262 display,
263 if count == 1 { "" } else { "s" }
264 )
265 } else {
266 format!(
267 "Removed {} stale host{}.",
268 count,
269 if count == 1 { "" } else { "s" }
270 )
271 };
272 app.notify(msg);
273}
274
275pub(super) fn handle_delete_key(app: &mut App, key: KeyEvent) {
283 let Screen::ConfirmDelete { alias } = &app.screen else {
284 return;
285 };
286 let alias = alias.clone();
287 match route_confirm_key(key) {
290 ConfirmAction::Yes => {
291 let siblings = app.hosts_state.ssh_config().siblings_of(&alias);
292
293 if !siblings.is_empty() {
294 app.hosts_state.ssh_config_mut().delete_host(&alias);
303 if let Err(e) = app.hosts_state.ssh_config().write() {
304 app.notify_error(crate::messages::failed_to_save(&e));
307 app.reload_hosts();
308 } else {
309 if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
310 let _ = tunnel.child.kill();
311 let _ = tunnel.child.wait();
312 }
313 app.update_last_modified();
314 app.reload_hosts();
315 app.notify(crate::messages::siblings_stripped(&alias, siblings.len()));
316 }
317 } else if let Some((element, position)) = app
318 .hosts_state
319 .ssh_config_mut()
320 .delete_host_undoable(&alias)
321 {
322 if let Err(e) = app.hosts_state.ssh_config().write() {
323 app.hosts_state
325 .ssh_config_mut()
326 .insert_host_at(element, position);
327 app.notify_error(crate::messages::failed_to_save(&e));
328 } else {
329 if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
331 let _ = tunnel.child.kill();
332 let _ = tunnel.child.wait();
333 }
334 let mut cert_cleanup_warning: Option<String> = None;
339 if !crate::demo_flag::is_demo() {
340 if let Ok(cert_path) =
341 crate::vault_ssh::cert_path_for(app.env().paths(), &alias)
342 {
343 match std::fs::remove_file(&cert_path) {
344 Ok(()) => {}
345 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
346 Err(e) => {
347 cert_cleanup_warning =
348 Some(crate::messages::cert_cleanup_warning(
349 &cert_path.display(),
350 &e,
351 ));
352 }
353 }
354 }
355 }
356 app.hosts_state
357 .undo_stack_mut()
358 .push(crate::app::DeletedHost { element, position });
359 if app.hosts_state.undo_stack().len() > 50 {
360 app.hosts_state.undo_stack_mut().remove(0);
361 }
362 app.update_last_modified();
363 app.reload_hosts();
364 if let Some(warning) = cert_cleanup_warning {
365 app.notify_error(warning);
366 } else {
367 app.notify(crate::messages::goodbye_host(&alias));
368 }
369 }
370 } else {
371 app.notify_warning(crate::messages::host_not_found(&alias));
372 }
373 app.set_screen(Screen::HostList);
374 }
375 ConfirmAction::No => {
376 app.set_screen(Screen::HostList);
377 }
378 ConfirmAction::Ignored => {}
379 }
380}
381
382pub(super) fn handle_vault_sign_key(
383 app: &mut App,
384 key: KeyEvent,
385 events_tx: &mpsc::Sender<AppEvent>,
386) {
387 let effects = {
394 let mut ctx = ConfirmCtx {
395 screen: &mut app.screen,
396 effects: Effects::default(),
397 };
398 match route_confirm_key(key) {
399 ConfirmAction::Yes => {
400 let signable = if let Screen::ConfirmVaultSign { signable } = &*ctx.screen {
406 signable.clone()
407 } else {
408 return;
409 };
410 ctx.set_screen(Screen::HostList);
411 let tx = events_tx.clone();
412 ctx.defer(move |app| start_vault_bulk_sign(app, signable, &tx));
413 }
414 ConfirmAction::No => {
415 ctx.set_screen(Screen::HostList);
416 }
417 ConfirmAction::Ignored => {}
418 }
419 ctx.effects
420 };
421 effects.apply(app);
422}
423
424fn start_vault_bulk_sign(
427 app: &mut App,
428 signable: Vec<crate::vault_ssh::VaultSignTarget>,
429 events_tx: &mpsc::Sender<AppEvent>,
430) {
431 let total = signable.len();
432 if total == 0 {
433 return;
434 }
435 app.notify_progress(crate::messages::vault_signing_progress(
436 crate::animation::SPINNER_FRAMES[0],
437 0,
438 total,
439 "",
440 ));
441
442 let cancel = Arc::new(AtomicBool::new(false));
443 app.vault.set_signing_cancel(cancel.clone());
444
445 let in_flight = app.vault.sign_in_flight().clone();
446 let tx = events_tx.clone();
447 let env = std::sync::Arc::clone(&app.env);
450 let spawn_result = std::thread::Builder::new()
451 .name("vault-bulk-sign".into())
452 .spawn(move || {
453 let mut signed = 0u32;
454 let mut failed = 0u32;
455 let mut skipped = 0u32;
456 let mut consecutive_failures = 0usize;
457 let mut first_error: Option<String> = None;
458 let mut aborted_message: Option<String> = None;
459
460 for (idx, target) in signable.iter().enumerate() {
461 let crate::vault_ssh::VaultSignTarget {
462 alias,
463 role,
464 certificate_file: cert_file,
465 pubkey,
466 vault_addr,
467 } = target;
468 if cancel.load(Ordering::Relaxed) {
469 break;
470 }
471 let done = idx + 1;
472
473 {
476 let mut set = match in_flight.lock() {
481 Ok(g) => g,
482 Err(p) => p.into_inner(),
483 };
484 if !set.insert(alias.clone()) {
485 skipped += 1;
486 let _ = tx.send(AppEvent::VaultSignProgress {
487 alias: alias.clone(),
488 done,
489 total,
490 });
491 continue;
492 }
493 }
494
495 let _ = tx.send(AppEvent::VaultSignProgress {
496 alias: alias.clone(),
497 done,
498 total,
499 });
500
501 let cert_path =
502 match crate::vault_ssh::resolve_cert_path(env.paths(), alias, cert_file) {
503 Ok(p) => p,
504 Err(e) => {
505 failed += 1;
506 consecutive_failures += 1;
507 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
508 if first_error.is_none() {
509 first_error = Some(scrubbed);
510 }
511 remove_in_flight(&in_flight, alias);
512 if consecutive_failures >= 3 {
513 aborted_message = Some(crate::messages::vault_signing_aborted(
514 failed,
515 first_error.as_deref(),
516 ));
517 break;
518 }
519 continue;
520 }
521 };
522 let status = crate::vault_ssh::check_cert_validity(&env, &cert_path);
523 if !crate::vault_ssh::needs_renewal(&status) {
524 skipped += 1;
525 consecutive_failures = 0;
526 remove_in_flight(&in_flight, alias);
527 continue;
528 }
529
530 let sign_result = crate::vault_ssh::sign_certificate(
531 &env,
532 role,
533 pubkey,
534 alias,
535 vault_addr.as_deref(),
536 );
537 remove_in_flight(&in_flight, alias);
541 match sign_result {
542 Ok(_) => {
543 let _ = tx.send(AppEvent::VaultSignResult {
544 alias: alias.clone(),
545 certificate_file: cert_file.clone(),
546 success: true,
547 message: String::new(),
548 });
549 signed += 1;
550 consecutive_failures = 0;
551 }
552 Err(e) => {
553 let raw = e.to_string();
554 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
555 if first_error.is_none() {
556 first_error = Some(scrubbed.clone());
557 }
558 let _ = tx.send(AppEvent::VaultSignResult {
559 alias: alias.clone(),
560 certificate_file: cert_file.clone(),
561 success: false,
562 message: scrubbed,
563 });
564 failed += 1;
565 consecutive_failures += 1;
566 if consecutive_failures >= 3 {
567 aborted_message = Some(crate::messages::vault_signing_aborted(
568 failed,
569 first_error.as_deref(),
570 ));
571 break;
572 }
573 }
574 }
575 }
576
577 let cancelled = cancel.load(Ordering::Relaxed);
578 let _ = tx.send(AppEvent::VaultSignAllDone {
579 signed,
580 failed,
581 skipped,
582 cancelled,
583 aborted_message,
584 first_error,
585 });
586 });
587 match spawn_result {
588 Ok(handle) => {
589 log::info!("[purple] vault sign thread: spawned");
590 app.vault.set_sign_thread(handle);
591 }
592 Err(e) => {
593 log::warn!("[purple] vault sign thread: spawn failed: {}", e);
597 let _ = app.vault.finalize_signing_run();
598 app.notify_error(crate::messages::vault_spawn_failed(&e));
599 }
600 }
601}
602
603pub(super) fn remove_in_flight(
604 set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
605 alias: &str,
606) {
607 let mut guard = match set.lock() {
611 Ok(g) => g,
612 Err(p) => p.into_inner(),
613 };
614 guard.remove(alias);
615}
616
617pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
618 let mut ctx = HostKeyResetCtx {
619 ui: &mut app.ui,
620 status: &mut app.status_center,
621 screen: &mut app.screen,
622 demo_mode: app.demo_mode,
623 };
624 let Screen::ConfirmHostKeyReset {
625 alias,
626 hostname,
627 known_hosts_path,
628 askpass,
629 } = &*ctx.screen
630 else {
631 return;
632 };
633 let alias = alias.clone();
634 let hostname = hostname.clone();
635 let known_hosts_path = known_hosts_path.clone();
636 let askpass = askpass.clone();
637 match route_confirm_key(key) {
640 ConfirmAction::Yes => {
641 let output = std::process::Command::new("ssh-keygen")
642 .arg("-R")
643 .arg(&hostname)
644 .arg("-f")
645 .arg(&known_hosts_path)
646 .output();
647
648 match output {
649 Ok(result) if result.status.success() => {
650 ctx.notify(crate::messages::removed_host_key(&hostname));
651 if ctx.demo_mode {
652 ctx.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
653 } else {
654 ctx.ui.queue_connect(alias, askpass);
655 }
656 }
657 Ok(result) => {
658 let stderr = String::from_utf8_lossy(&result.stderr);
659 ctx.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
660 }
661 Err(e) => {
662 ctx.notify_error(crate::messages::ssh_keygen_failed(&e));
663 }
664 }
665 ctx.set_screen(Screen::HostList);
666 }
667 ConfirmAction::No => {
668 ctx.set_screen(Screen::HostList);
669 }
670 ConfirmAction::Ignored => {}
671 }
672}
673
674struct ContainerConfirm {
677 alias: String,
678 targets: Vec<(String, String)>,
679 action: crate::containers::ContainerAction,
680}
681
682fn apply_container_confirm(
686 ctx: &mut ContainerConfirmCtx,
687 key: KeyEvent,
688 confirm: ContainerConfirm,
689) {
690 match route_confirm_key(key) {
691 ConfirmAction::Yes => {
692 for (container_id, container_name) in confirm.targets {
693 queue_container_action(
694 ctx,
695 confirm.alias.clone(),
696 container_id,
697 container_name,
698 confirm.action,
699 );
700 }
701 ctx.set_screen(Screen::HostList);
702 }
703 ConfirmAction::No => {
704 ctx.set_screen(Screen::HostList);
705 }
706 ConfirmAction::Ignored => {}
707 }
708}
709
710fn container_confirm_ctx(app: &mut App) -> ContainerConfirmCtx<'_> {
712 ContainerConfirmCtx {
713 container_state: &mut app.container_state,
714 hosts: &app.hosts_state,
715 screen: &mut app.screen,
716 }
717}
718
719pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
721 let mut ctx = container_confirm_ctx(app);
722 let Screen::ConfirmContainerRestart {
723 alias,
724 container_id,
725 container_name,
726 ..
727 } = &*ctx.screen
728 else {
729 return;
730 };
731 let confirm = ContainerConfirm {
732 alias: alias.clone(),
733 targets: vec![(container_id.clone(), container_name.clone())],
734 action: crate::containers::ContainerAction::Restart,
735 };
736 apply_container_confirm(&mut ctx, key, confirm);
737}
738
739pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
741 let mut ctx = container_confirm_ctx(app);
742 let Screen::ConfirmContainerStop {
743 alias,
744 container_id,
745 container_name,
746 ..
747 } = &*ctx.screen
748 else {
749 return;
750 };
751 let confirm = ContainerConfirm {
752 alias: alias.clone(),
753 targets: vec![(container_id.clone(), container_name.clone())],
754 action: crate::containers::ContainerAction::Stop,
755 };
756 apply_container_confirm(&mut ctx, key, confirm);
757}
758
759pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
763 let mut ctx = container_confirm_ctx(app);
764 let Screen::ConfirmStackRestart { alias, members, .. } = &*ctx.screen else {
765 return;
766 };
767 let confirm = ContainerConfirm {
768 alias: alias.clone(),
769 targets: members
770 .iter()
771 .map(|m| (m.container_id.clone(), m.container_name.clone()))
772 .collect(),
773 action: crate::containers::ContainerAction::Restart,
774 };
775 apply_container_confirm(&mut ctx, key, confirm);
776}
777
778pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
781 let mut ctx = container_confirm_ctx(app);
782 let Screen::ConfirmHostRestartAll { alias, members } = &*ctx.screen else {
783 return;
784 };
785 let confirm = ContainerConfirm {
786 alias: alias.clone(),
787 targets: members
788 .iter()
789 .map(|m| (m.container_id.clone(), m.container_name.clone()))
790 .collect(),
791 action: crate::containers::ContainerAction::Restart,
792 };
793 apply_container_confirm(&mut ctx, key, confirm);
794}
795
796pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
799 let mut ctx = container_confirm_ctx(app);
800 let Screen::ConfirmHostStopAll { alias, members } = &*ctx.screen else {
801 return;
802 };
803 let confirm = ContainerConfirm {
804 alias: alias.clone(),
805 targets: members
806 .iter()
807 .map(|m| (m.container_id.clone(), m.container_name.clone()))
808 .collect(),
809 action: crate::containers::ContainerAction::Stop,
810 };
811 apply_container_confirm(&mut ctx, key, confirm);
812}
813
814fn queue_container_action(
815 ctx: &mut ContainerConfirmCtx,
816 alias: String,
817 container_id: String,
818 container_name: String,
819 action: crate::containers::ContainerAction,
820) {
821 let Some(entry) = ctx.container_state.cache_entry(&alias) else {
822 log::debug!(
823 "[purple] container_action: queue aborted, no cache for alias={}",
824 alias
825 );
826 return;
827 };
828 let runtime = entry.runtime;
829 let askpass = ctx
830 .hosts
831 .list()
832 .iter()
833 .find(|h| h.alias == alias)
834 .and_then(|h| h.askpass.clone());
835 log::info!(
836 "[purple] container_action queued: alias={} id={} action={:?}",
837 alias,
838 container_id,
839 action
840 );
841 ctx.container_state
842 .queue_action(crate::app::ContainerActionRequest {
843 alias,
844 askpass,
845 runtime,
846 container_id,
847 container_name,
848 action,
849 });
850}
851
852pub(super) fn handle_key_push_key(
856 app: &mut App,
857 key: KeyEvent,
858 events_tx: &mpsc::Sender<AppEvent>,
859) {
860 let effects = {
861 let mut ctx = KeyPushConfirmCtx {
862 keys: &mut app.keys,
863 screen: &mut app.screen,
864 effects: Effects::default(),
865 };
866 match route_confirm_key(key) {
867 ConfirmAction::Yes => {
868 let key_index = match &*ctx.screen {
869 Screen::ConfirmKeyPush { key_index } => *key_index,
870 _ => return,
871 };
872 let aliases = std::mem::take(&mut ctx.keys.push_mut().committed);
873 ctx.set_screen(Screen::HostList);
874 let tx = events_tx.clone();
879 ctx.defer(move |app| start_key_push(app, key_index, aliases, &tx));
880 }
881 ConfirmAction::No => {
882 let key_index = match &*ctx.screen {
885 Screen::ConfirmKeyPush { key_index } => *key_index,
886 _ => return,
887 };
888 ctx.keys.push_mut().committed.clear();
889 ctx.set_screen(Screen::KeyPushPicker { key_index });
890 }
891 ConfirmAction::Ignored => {}
892 }
893 ctx.effects
894 };
895 effects.apply(app);
896}
897
898fn start_key_push(
905 app: &mut App,
906 key_index: usize,
907 aliases: Vec<String>,
908 events_tx: &mpsc::Sender<AppEvent>,
909) {
910 if app.keys.push().expected_count > 0
916 || app
917 .keys
918 .push()
919 .worker
920 .as_ref()
921 .is_some_and(|h| !h.is_finished())
922 {
923 log::debug!(
924 "[purple] key_push: rejected second push, run already in progress ({} of {})",
925 app.keys.push().results.len(),
926 app.keys.push().expected_count
927 );
928 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
929 return;
930 }
931 if aliases.is_empty() {
932 log::debug!("[purple] key_push: rejected, no aliases committed");
933 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
934 return;
935 }
936 let Some(key_info) = app.keys.list().get(key_index).cloned() else {
937 return;
938 };
939 if key_info.is_certificate {
940 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
941 return;
942 }
943 let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
944 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
945 Ok(s) => s,
946 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
947 log::warn!(
948 "[purple] key_push: pubkey too large path={} bytes={}",
949 pub_path.display(),
950 n
951 );
952 app.notify_error(crate::messages::key_push_pubkey_too_large(
953 &key_info.name,
954 n,
955 ));
956 return;
957 }
958 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
959 log::warn!(
960 "[purple] key_push: pubkey not a regular file path={}",
961 pub_path.display()
962 );
963 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
964 return;
965 }
966 Err(_) => {
967 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
971 return;
972 }
973 };
974 let pubkey = match crate::key_push::validate_pubkey(&raw) {
975 Ok(s) => s,
976 Err(err) => {
977 let detail = match &err {
978 crate::key_push::PubkeyValidationError::Empty => "file is empty",
979 crate::key_push::PubkeyValidationError::MultiLine => {
980 "must be a single line; multi-line input is rejected"
981 }
982 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
983 "key algorithm not allowed for static push"
984 }
985 crate::key_push::PubkeyValidationError::MalformedBase64 => {
986 "base64 key body did not parse"
987 }
988 _ => "unexpected format",
989 };
990 log::warn!(
991 "[purple] key_push: invalid pubkey path={} err={:?}",
992 pub_path.display(),
993 err
994 );
995 app.notify_error(crate::messages::key_push_invalid_pubkey(
996 &key_info.name,
997 detail,
998 ));
999 return;
1000 }
1001 };
1002
1003 let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
1005
1006 app.notify_progress(crate::messages::key_push_in_progress(
1007 &key_info.name,
1008 aliases.len(),
1009 ));
1010
1011 let config_path = app.hosts_state.ssh_config().path.clone();
1012 let tx = events_tx.clone();
1013 let pubkey_payload = pubkey;
1014 let handle = std::thread::Builder::new()
1015 .name("key-push".into())
1016 .spawn(move || {
1017 for alias in aliases {
1018 if cancel.load(Ordering::Relaxed) {
1019 break;
1020 }
1021 let outcome =
1022 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
1023 let _ = tx.send(AppEvent::KeyPushResult {
1024 run_id,
1025 result: crate::key_push::KeyPushResult { alias, outcome },
1026 });
1027 }
1028 });
1029 match handle {
1030 Ok(h) => {
1031 app.keys.push_mut().worker = Some(h);
1032 }
1033 Err(e) => {
1034 log::error!("[purple] key_push: failed to spawn worker: {}", e);
1035 app.status_center.clear_sticky_status();
1039 app.notify_error(crate::messages::key_push_thread_spawn_failed());
1040 app.keys.push_mut().clear_inflight_state();
1041 }
1042 }
1043}
1044
1045#[cfg(test)]
1046mod key_push_confirm_tests {
1047 use super::*;
1054 use crate::ssh_config::model::SshConfigFile;
1055 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1056
1057 fn make_app() -> (App, std::path::PathBuf) {
1058 let scratch = tempfile::tempdir().expect("tempdir").keep();
1059 let config = SshConfigFile {
1060 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
1061 path: scratch.join("test_config"),
1062 crlf: false,
1063 bom: false,
1064 };
1065 let mut app = App::new(config);
1066 let pub_path = scratch.join("id_test.pub");
1069 std::fs::write(
1070 &pub_path,
1071 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
1072 )
1073 .unwrap();
1074 app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
1075 name: "id_test".into(),
1076 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
1077 key_type: "ED25519".into(),
1078 bits: "256".into(),
1079 fingerprint: String::new(),
1080 comment: "test@host".into(),
1081 linked_hosts: vec![],
1082 bishop_art: String::new(),
1083 strength_score: 95,
1084 encrypted: false,
1085 agent_loaded: false,
1086 is_certificate: false,
1087 mtime_ts: None,
1088 });
1089 (app, scratch)
1090 }
1091
1092 fn k(code: KeyCode) -> KeyEvent {
1093 KeyEvent::new(code, KeyModifiers::NONE)
1094 }
1095
1096 #[test]
1097 fn n_returns_to_picker_with_key_index_preserved() {
1098 let (mut app, _scratch) = make_app();
1099 app.keys.push_mut().committed = vec!["h1".into()];
1100 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1101 let (tx, _rx) = mpsc::channel();
1102 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
1103 match app.screen {
1104 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
1105 ref other => panic!("expected KeyPushPicker, got {:?}", other),
1106 }
1107 assert!(
1108 app.keys.push().committed.is_empty(),
1109 "n should drop the frozen selection"
1110 );
1111 }
1112
1113 #[test]
1114 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
1115 let (mut app, _scratch) = make_app();
1116 app.keys.push_mut().committed = vec!["h1".into()];
1117 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
1118 let (tx, _rx) = mpsc::channel();
1119 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
1120 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
1121 }
1122
1123 #[test]
1124 fn start_rejects_when_a_previous_run_is_still_in_flight() {
1125 let (mut app, _scratch) = make_app();
1126 app.keys.push_mut().expected_count = 2;
1127 app.keys
1128 .push_mut()
1129 .results
1130 .push(crate::key_push::KeyPushResult {
1131 alias: "h1".into(),
1132 outcome: crate::key_push::KeyPushOutcome::Appended,
1133 });
1134 let (tx, _rx) = mpsc::channel();
1135 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1136 assert_eq!(
1137 app.keys.push().expected_count,
1138 2,
1139 "guard must not reset in-flight state"
1140 );
1141 let toast = app.status_center.toast().expect("toast set");
1142 assert!(
1143 toast.text.contains("already running"),
1144 "expected 'already running' warning, got: {}",
1145 toast.text
1146 );
1147 }
1148
1149 #[test]
1150 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1151 let (mut app, _scratch) = make_app();
1152 let (tx, _rx) = mpsc::channel();
1153 start_key_push(&mut app, 0, Vec::new(), &tx);
1154 assert_eq!(app.keys.push().expected_count, 0);
1155 assert!(app.keys.push().worker.is_none());
1156 let toast = app.status_center.toast().expect("toast set");
1157 assert!(toast.is_error());
1158 }
1159
1160 #[test]
1161 fn start_rejects_certificate_key() {
1162 let (mut app, _scratch) = make_app();
1163 app.keys.list_mut()[0].is_certificate = true;
1164 let (tx, _rx) = mpsc::channel();
1165 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1166 assert_eq!(app.keys.push().expected_count, 0);
1167 assert!(app.keys.push().worker.is_none());
1168 let toast = app.status_center.toast().expect("toast set");
1169 assert!(toast.is_error());
1170 assert!(toast.text.contains("Certificates"));
1171 }
1172
1173 #[test]
1174 fn start_rejects_missing_pubkey_file() {
1175 let (mut app, _scratch) = make_app();
1176 app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1177 let (tx, _rx) = mpsc::channel();
1178 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1179 assert_eq!(app.keys.push().expected_count, 0);
1180 let toast = app.status_center.toast().expect("toast set");
1181 assert!(toast.is_error());
1182 }
1183
1184 #[test]
1185 fn start_rejects_invalid_pubkey_content() {
1186 let (mut app, scratch) = make_app();
1187 let pub_path = scratch.join("id_bad.pub");
1190 std::fs::write(
1191 &pub_path,
1192 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1193 )
1194 .unwrap();
1195 app.keys.list_mut()[0].display_path =
1196 pub_path.with_extension("").to_string_lossy().into_owned();
1197 app.keys.list_mut()[0].name = "id_bad".into();
1198 let (tx, _rx) = mpsc::channel();
1199 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1200 assert_eq!(app.keys.push().expected_count, 0);
1201 assert!(app.keys.push().worker.is_none());
1202 let toast = app.status_center.toast().expect("toast set");
1203 assert!(toast.is_error());
1204 assert!(toast.text.contains("validation"));
1205 }
1206}