1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::mpsc;
4
5use crossterm::event::{KeyCode, KeyEvent};
6
7use crate::app::{App, Screen};
8use crate::event::AppEvent;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ConfirmAction {
23 Yes,
24 No,
25 Ignored,
26}
27
28pub fn route_confirm_key(key: KeyEvent) -> ConfirmAction {
30 match key.code {
31 KeyCode::Char('y') | KeyCode::Char('Y') => ConfirmAction::Yes,
32 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => ConfirmAction::No,
33 _ => ConfirmAction::Ignored,
34 }
35}
36
37pub(super) fn execute_known_hosts_import(app: &mut App) {
39 let config_backup = app.hosts_state.ssh_config.clone();
40 match crate::import::import_from_known_hosts(
41 &mut app.hosts_state.ssh_config,
42 Some("known_hosts"),
43 ) {
44 Ok((imported, skipped, _, _)) => {
45 if imported > 0 {
46 if let Err(e) = app.hosts_state.ssh_config.write() {
47 app.hosts_state.ssh_config = config_backup;
48 app.notify_error(crate::messages::failed_to_save(&e));
49 return;
50 }
51 app.reload_hosts();
52 app.notify(crate::messages::imported_hosts(imported, skipped));
53 } else {
54 app.notify(crate::messages::all_hosts_exist(skipped));
55 }
56 app.ui.known_hosts_count = 0;
57 }
58 Err(e) => {
59 app.notify_error(e);
60 }
61 }
62}
63
64pub(super) fn handle_import_key(app: &mut App, key: KeyEvent) {
65 match route_confirm_key(key) {
66 ConfirmAction::Yes => {
67 app.set_screen(Screen::HostList);
68 execute_known_hosts_import(app);
69 }
70 ConfirmAction::No => {
71 app.set_screen(Screen::HostList);
72 }
73 ConfirmAction::Ignored => {}
74 }
75}
76
77pub(super) fn handle_purge_stale_key(app: &mut App, key: KeyEvent) {
78 let Screen::ConfirmPurgeStale { provider: p, .. } = &app.screen else {
79 return;
80 };
81 let provider = p.clone();
82 let return_screen = if provider.is_some() {
83 Screen::Providers
84 } else {
85 Screen::HostList
86 };
87 match route_confirm_key(key) {
88 ConfirmAction::Yes => {
89 execute_purge_stale(app, provider.as_deref());
90 app.screen = return_screen;
91 }
92 ConfirmAction::No => {
93 app.screen = return_screen;
94 }
95 ConfirmAction::Ignored => {}
96 }
97}
98
99fn execute_purge_stale(app: &mut App, provider: Option<&str>) {
100 let stale = app.hosts_state.ssh_config.stale_hosts();
101 if stale.is_empty() {
102 return;
103 }
104 let targets: Vec<(String, u64)> = if let Some(prov) = provider {
106 stale
107 .into_iter()
108 .filter(|(alias, _)| {
109 app.hosts_state
110 .ssh_config
111 .host_entries()
112 .iter()
113 .any(|e| e.alias == *alias && e.provider.as_deref() == Some(prov))
114 })
115 .collect()
116 } else {
117 stale
118 };
119 if targets.is_empty() {
120 return;
121 }
122 let config_backup = app.hosts_state.ssh_config.clone();
123 let count = targets.len();
124 for (alias, _) in &targets {
125 app.hosts_state.ssh_config.delete_host(alias);
126 }
127 if let Err(e) = app.hosts_state.ssh_config.write() {
128 app.hosts_state.ssh_config = config_backup;
129 app.notify_error(crate::messages::failed_to_save(&e));
130 return;
131 }
132 for (alias, _) in &targets {
134 if let Some(mut tunnel) = app.tunnels.active.remove(alias) {
135 let _ = tunnel.child.kill();
136 let _ = tunnel.child.wait();
137 }
138 }
139 app.hosts_state.undo_stack.clear();
140 app.update_last_modified();
141 app.reload_hosts();
142 let msg = if let Some(prov) = provider {
143 let display = crate::providers::provider_display_name(prov);
144 format!(
145 "Removed {} stale {} host{}.",
146 count,
147 display,
148 if count == 1 { "" } else { "s" }
149 )
150 } else {
151 format!(
152 "Removed {} stale host{}.",
153 count,
154 if count == 1 { "" } else { "s" }
155 )
156 };
157 app.notify(msg);
158}
159
160pub(super) fn handle_delete_key(app: &mut App, key: KeyEvent) {
161 let Screen::ConfirmDelete { alias } = &app.screen else {
162 return;
163 };
164 let alias = alias.clone();
165 match route_confirm_key(key) {
168 ConfirmAction::Yes => {
169 let siblings = app.hosts_state.ssh_config.siblings_of(&alias);
170
171 if !siblings.is_empty() {
172 app.hosts_state.ssh_config.delete_host(&alias);
181 if let Err(e) = app.hosts_state.ssh_config.write() {
182 app.notify_error(crate::messages::failed_to_save(&e));
185 app.reload_hosts();
186 } else {
187 if let Some(mut tunnel) = app.tunnels.active.remove(&alias) {
188 let _ = tunnel.child.kill();
189 let _ = tunnel.child.wait();
190 }
191 app.update_last_modified();
192 app.reload_hosts();
193 app.notify(crate::messages::siblings_stripped(&alias, siblings.len()));
194 }
195 } else if let Some((element, position)) =
196 app.hosts_state.ssh_config.delete_host_undoable(&alias)
197 {
198 if let Err(e) = app.hosts_state.ssh_config.write() {
199 app.hosts_state.ssh_config.insert_host_at(element, position);
201 app.notify_error(crate::messages::failed_to_save(&e));
202 } else {
203 if let Some(mut tunnel) = app.tunnels.active.remove(&alias) {
205 let _ = tunnel.child.kill();
206 let _ = tunnel.child.wait();
207 }
208 let mut cert_cleanup_warning: Option<String> = None;
213 if !crate::demo_flag::is_demo() {
214 if let Ok(cert_path) = crate::vault_ssh::cert_path_for(&alias) {
215 match std::fs::remove_file(&cert_path) {
216 Ok(()) => {}
217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
218 Err(e) => {
219 cert_cleanup_warning =
220 Some(crate::messages::cert_cleanup_warning(
221 &cert_path.display(),
222 &e,
223 ));
224 }
225 }
226 }
227 }
228 app.hosts_state
229 .undo_stack
230 .push(crate::app::DeletedHost { element, position });
231 if app.hosts_state.undo_stack.len() > 50 {
232 app.hosts_state.undo_stack.remove(0);
233 }
234 app.update_last_modified();
235 app.reload_hosts();
236 if let Some(warning) = cert_cleanup_warning {
237 app.notify_error(warning);
238 } else {
239 app.notify(crate::messages::goodbye_host(&alias));
240 }
241 }
242 } else {
243 app.notify_warning(crate::messages::host_not_found(&alias));
244 }
245 app.set_screen(Screen::HostList);
246 }
247 ConfirmAction::No => {
248 app.set_screen(Screen::HostList);
249 }
250 ConfirmAction::Ignored => {}
251 }
252}
253
254pub(super) fn handle_vault_sign_key(
255 app: &mut App,
256 key: KeyEvent,
257 events_tx: &mpsc::Sender<AppEvent>,
258) {
259 match route_confirm_key(key) {
266 ConfirmAction::Yes => {
267 let signable = if let Screen::ConfirmVaultSign { signable } = &app.screen {
270 signable.clone()
271 } else {
272 return;
273 };
274 app.set_screen(Screen::HostList);
275 start_vault_bulk_sign(app, signable, events_tx);
276 }
277 ConfirmAction::No => {
278 app.set_screen(Screen::HostList);
279 }
280 ConfirmAction::Ignored => {}
281 }
282}
283
284fn start_vault_bulk_sign(
287 app: &mut App,
288 signable: Vec<crate::vault_ssh::VaultSignTarget>,
289 events_tx: &mpsc::Sender<AppEvent>,
290) {
291 let total = signable.len();
292 if total == 0 {
293 return;
294 }
295 app.notify_progress(crate::messages::vault_signing_progress(
296 crate::animation::SPINNER_FRAMES[0],
297 0,
298 total,
299 "",
300 ));
301
302 let cancel = Arc::new(AtomicBool::new(false));
303 app.vault.signing_cancel = Some(cancel.clone());
304
305 let in_flight = app.vault.sign_in_flight.clone();
306 let tx = events_tx.clone();
307 let spawn_result = std::thread::Builder::new()
308 .name("vault-bulk-sign".into())
309 .spawn(move || {
310 let mut signed = 0u32;
311 let mut failed = 0u32;
312 let mut skipped = 0u32;
313 let mut consecutive_failures = 0usize;
314 let mut first_error: Option<String> = None;
315 let mut aborted_message: Option<String> = None;
316
317 for (idx, target) in signable.iter().enumerate() {
318 let crate::vault_ssh::VaultSignTarget {
319 alias,
320 role,
321 certificate_file: cert_file,
322 pubkey,
323 vault_addr,
324 } = target;
325 if cancel.load(Ordering::Relaxed) {
326 break;
327 }
328 let done = idx + 1;
329
330 {
333 let mut set = match in_flight.lock() {
338 Ok(g) => g,
339 Err(p) => p.into_inner(),
340 };
341 if !set.insert(alias.clone()) {
342 skipped += 1;
343 let _ = tx.send(AppEvent::VaultSignProgress {
344 alias: alias.clone(),
345 done,
346 total,
347 });
348 continue;
349 }
350 }
351
352 let _ = tx.send(AppEvent::VaultSignProgress {
353 alias: alias.clone(),
354 done,
355 total,
356 });
357
358 let cert_path = match crate::vault_ssh::resolve_cert_path(alias, cert_file) {
359 Ok(p) => p,
360 Err(e) => {
361 failed += 1;
362 consecutive_failures += 1;
363 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
364 if first_error.is_none() {
365 first_error = Some(scrubbed);
366 }
367 remove_in_flight(&in_flight, alias);
368 if consecutive_failures >= 3 {
369 aborted_message = Some(crate::messages::vault_signing_aborted(
370 failed,
371 first_error.as_deref(),
372 ));
373 break;
374 }
375 continue;
376 }
377 };
378 let status = crate::vault_ssh::check_cert_validity(&cert_path);
379 if !crate::vault_ssh::needs_renewal(&status) {
380 skipped += 1;
381 consecutive_failures = 0;
382 remove_in_flight(&in_flight, alias);
383 continue;
384 }
385
386 let sign_result =
387 crate::vault_ssh::sign_certificate(role, pubkey, alias, vault_addr.as_deref());
388 remove_in_flight(&in_flight, alias);
392 match sign_result {
393 Ok(_) => {
394 let _ = tx.send(AppEvent::VaultSignResult {
395 alias: alias.clone(),
396 certificate_file: cert_file.clone(),
397 success: true,
398 message: String::new(),
399 });
400 signed += 1;
401 consecutive_failures = 0;
402 }
403 Err(e) => {
404 let raw = e.to_string();
405 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
406 if first_error.is_none() {
407 first_error = Some(scrubbed.clone());
408 }
409 let _ = tx.send(AppEvent::VaultSignResult {
410 alias: alias.clone(),
411 certificate_file: cert_file.clone(),
412 success: false,
413 message: scrubbed,
414 });
415 failed += 1;
416 consecutive_failures += 1;
417 if consecutive_failures >= 3 {
418 aborted_message = Some(crate::messages::vault_signing_aborted(
419 failed,
420 first_error.as_deref(),
421 ));
422 break;
423 }
424 }
425 }
426 }
427
428 let cancelled = cancel.load(Ordering::Relaxed);
429 let _ = tx.send(AppEvent::VaultSignAllDone {
430 signed,
431 failed,
432 skipped,
433 cancelled,
434 aborted_message,
435 first_error,
436 });
437 });
438 match spawn_result {
439 Ok(handle) => {
440 log::info!("[purple] vault sign thread: spawned");
441 app.vault.sign_thread = Some(handle);
442 }
443 Err(e) => {
444 log::warn!("[purple] vault sign thread: spawn failed: {}", e);
448 app.vault.signing_cancel = None;
449 app.vault.sign_thread = None;
450 app.notify_error(crate::messages::vault_spawn_failed(&e));
451 }
452 }
453}
454
455pub(super) fn remove_in_flight(
456 set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
457 alias: &str,
458) {
459 let mut guard = match set.lock() {
463 Ok(g) => g,
464 Err(p) => p.into_inner(),
465 };
466 guard.remove(alias);
467}
468
469pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
470 let Screen::ConfirmHostKeyReset {
471 alias,
472 hostname,
473 known_hosts_path,
474 askpass,
475 } = &app.screen
476 else {
477 return;
478 };
479 let alias = alias.clone();
480 let hostname = hostname.clone();
481 let known_hosts_path = known_hosts_path.clone();
482 let askpass = askpass.clone();
483 match route_confirm_key(key) {
486 ConfirmAction::Yes => {
487 let output = std::process::Command::new("ssh-keygen")
488 .arg("-R")
489 .arg(&hostname)
490 .arg("-f")
491 .arg(&known_hosts_path)
492 .output();
493
494 match output {
495 Ok(result) if result.status.success() => {
496 app.notify(crate::messages::removed_host_key(&hostname));
497 if app.demo_mode {
498 app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
499 } else {
500 app.ui.queue_connect(alias, askpass);
501 }
502 }
503 Ok(result) => {
504 let stderr = String::from_utf8_lossy(&result.stderr);
505 app.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
506 }
507 Err(e) => {
508 app.notify_error(crate::messages::ssh_keygen_failed(&e));
509 }
510 }
511 app.set_screen(Screen::HostList);
512 }
513 ConfirmAction::No => {
514 app.set_screen(Screen::HostList);
515 }
516 ConfirmAction::Ignored => {}
517 }
518}
519
520pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
525 let Screen::ConfirmContainerRestart {
526 alias,
527 container_id,
528 container_name,
529 ..
530 } = &app.screen
531 else {
532 return;
533 };
534 let alias = alias.clone();
535 let container_id = container_id.clone();
536 let container_name = container_name.clone();
537 match route_confirm_key(key) {
538 ConfirmAction::Yes => {
539 queue_container_action(
540 app,
541 alias,
542 container_id,
543 container_name,
544 crate::containers::ContainerAction::Restart,
545 );
546 app.set_screen(Screen::HostList);
547 }
548 ConfirmAction::No => {
549 app.set_screen(Screen::HostList);
550 }
551 ConfirmAction::Ignored => {}
552 }
553}
554
555pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
559 let Screen::ConfirmContainerStop {
560 alias,
561 container_id,
562 container_name,
563 ..
564 } = &app.screen
565 else {
566 return;
567 };
568 let alias = alias.clone();
569 let container_id = container_id.clone();
570 let container_name = container_name.clone();
571 match route_confirm_key(key) {
572 ConfirmAction::Yes => {
573 queue_container_action(
574 app,
575 alias,
576 container_id,
577 container_name,
578 crate::containers::ContainerAction::Stop,
579 );
580 app.set_screen(Screen::HostList);
581 }
582 ConfirmAction::No => {
583 app.set_screen(Screen::HostList);
584 }
585 ConfirmAction::Ignored => {}
586 }
587}
588
589pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
594 let Screen::ConfirmStackRestart { alias, members, .. } = &app.screen else {
595 return;
596 };
597 let alias = alias.clone();
598 let members = members.clone();
599 match route_confirm_key(key) {
600 ConfirmAction::Yes => {
601 for m in members {
602 queue_container_action(
603 app,
604 alias.clone(),
605 m.container_id,
606 m.container_name,
607 crate::containers::ContainerAction::Restart,
608 );
609 }
610 app.set_screen(Screen::HostList);
611 }
612 ConfirmAction::No => {
613 app.set_screen(Screen::HostList);
614 }
615 ConfirmAction::Ignored => {}
616 }
617}
618
619pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
624 let Screen::ConfirmHostRestartAll { alias, members } = &app.screen else {
625 return;
626 };
627 let alias = alias.clone();
628 let members = members.clone();
629 match route_confirm_key(key) {
630 ConfirmAction::Yes => {
631 for m in members {
632 queue_container_action(
633 app,
634 alias.clone(),
635 m.container_id,
636 m.container_name,
637 crate::containers::ContainerAction::Restart,
638 );
639 }
640 app.set_screen(Screen::HostList);
641 }
642 ConfirmAction::No => {
643 app.set_screen(Screen::HostList);
644 }
645 ConfirmAction::Ignored => {}
646 }
647}
648
649pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
652 let Screen::ConfirmHostStopAll { alias, members } = &app.screen else {
653 return;
654 };
655 let alias = alias.clone();
656 let members = members.clone();
657 match route_confirm_key(key) {
658 ConfirmAction::Yes => {
659 for m in members {
660 queue_container_action(
661 app,
662 alias.clone(),
663 m.container_id,
664 m.container_name,
665 crate::containers::ContainerAction::Stop,
666 );
667 }
668 app.set_screen(Screen::HostList);
669 }
670 ConfirmAction::No => {
671 app.set_screen(Screen::HostList);
672 }
673 ConfirmAction::Ignored => {}
674 }
675}
676
677fn queue_container_action(
678 app: &mut App,
679 alias: String,
680 container_id: String,
681 container_name: String,
682 action: crate::containers::ContainerAction,
683) {
684 let Some(entry) = app.container_state.cache.get(&alias) else {
685 log::debug!(
686 "[purple] container_action: queue aborted, no cache for alias={}",
687 alias
688 );
689 return;
690 };
691 let runtime = entry.runtime;
692 let askpass = app
693 .hosts_state
694 .list
695 .iter()
696 .find(|h| h.alias == alias)
697 .and_then(|h| h.askpass.clone());
698 log::info!(
699 "[purple] container_action queued: alias={} id={} action={:?}",
700 alias,
701 container_id,
702 action
703 );
704 app.container_state
705 .pending_actions
706 .push_back(crate::app::ContainerActionRequest {
707 alias,
708 askpass,
709 runtime,
710 container_id,
711 container_name,
712 action,
713 });
714}
715
716pub(super) fn handle_key_push_key(
720 app: &mut App,
721 key: KeyEvent,
722 events_tx: &mpsc::Sender<AppEvent>,
723) {
724 match route_confirm_key(key) {
725 ConfirmAction::Yes => {
726 let key_index = match &app.screen {
727 Screen::ConfirmKeyPush { key_index } => *key_index,
728 _ => return,
729 };
730 let aliases = std::mem::take(&mut app.keys.push.committed);
731 app.set_screen(Screen::HostList);
732 start_key_push(app, key_index, aliases, events_tx);
733 }
734 ConfirmAction::No => {
735 let key_index = match &app.screen {
738 Screen::ConfirmKeyPush { key_index } => *key_index,
739 _ => return,
740 };
741 app.keys.push.committed.clear();
742 app.set_screen(Screen::KeyPushPicker { key_index });
743 }
744 ConfirmAction::Ignored => {}
745 }
746}
747
748fn start_key_push(
755 app: &mut App,
756 key_index: usize,
757 aliases: Vec<String>,
758 events_tx: &mpsc::Sender<AppEvent>,
759) {
760 if app.keys.push.expected_count > 0
766 || app
767 .keys
768 .push
769 .worker
770 .as_ref()
771 .is_some_and(|h| !h.is_finished())
772 {
773 log::debug!(
774 "[purple] key_push: rejected second push, run already in progress ({} of {})",
775 app.keys.push.results.len(),
776 app.keys.push.expected_count
777 );
778 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
779 return;
780 }
781 if aliases.is_empty() {
782 log::debug!("[purple] key_push: rejected, no aliases committed");
783 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
784 return;
785 }
786 let Some(key_info) = app.keys.list.get(key_index).cloned() else {
787 return;
788 };
789 if key_info.is_certificate {
790 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
791 return;
792 }
793 let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
794 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
795 Ok(s) => s,
796 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
797 log::warn!(
798 "[purple] key_push: pubkey too large path={} bytes={}",
799 pub_path.display(),
800 n
801 );
802 app.notify_error(crate::messages::key_push_pubkey_too_large(
803 &key_info.name,
804 n,
805 ));
806 return;
807 }
808 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
809 log::warn!(
810 "[purple] key_push: pubkey not a regular file path={}",
811 pub_path.display()
812 );
813 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
814 return;
815 }
816 Err(_) => {
817 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
821 return;
822 }
823 };
824 let pubkey = match crate::key_push::validate_pubkey(&raw) {
825 Ok(s) => s,
826 Err(err) => {
827 let detail = match &err {
828 crate::key_push::PubkeyValidationError::Empty => "file is empty",
829 crate::key_push::PubkeyValidationError::MultiLine => {
830 "must be a single line; multi-line input is rejected"
831 }
832 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
833 "key algorithm not allowed for static push"
834 }
835 crate::key_push::PubkeyValidationError::MalformedBase64 => {
836 "base64 key body did not parse"
837 }
838 _ => "unexpected format",
839 };
840 log::warn!(
841 "[purple] key_push: invalid pubkey path={} err={:?}",
842 pub_path.display(),
843 err
844 );
845 app.notify_error(crate::messages::key_push_invalid_pubkey(
846 &key_info.name,
847 detail,
848 ));
849 return;
850 }
851 };
852
853 let (run_id, cancel) = app.keys.push.start_run(aliases.len());
855
856 app.notify_progress(crate::messages::key_push_in_progress(
857 &key_info.name,
858 aliases.len(),
859 ));
860
861 let config_path = app.hosts_state.ssh_config.path.clone();
862 let tx = events_tx.clone();
863 let pubkey_payload = pubkey;
864 let handle = std::thread::Builder::new()
865 .name("key-push".into())
866 .spawn(move || {
867 for alias in aliases {
868 if cancel.load(Ordering::Relaxed) {
869 break;
870 }
871 let outcome =
872 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
873 let _ = tx.send(AppEvent::KeyPushResult {
874 run_id,
875 result: crate::key_push::KeyPushResult { alias, outcome },
876 });
877 }
878 });
879 match handle {
880 Ok(h) => {
881 app.keys.push.worker = Some(h);
882 }
883 Err(e) => {
884 log::error!("[purple] key_push: failed to spawn worker: {}", e);
885 app.status_center.clear_sticky_status();
889 app.notify_error(crate::messages::key_push_thread_spawn_failed());
890 app.keys.push.clear_inflight_state();
891 }
892 }
893}
894
895#[cfg(test)]
896mod key_push_confirm_tests {
897 use super::*;
904 use crate::ssh_config::model::SshConfigFile;
905 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
906
907 fn make_app() -> (App, std::path::PathBuf) {
908 let scratch = tempfile::tempdir().expect("tempdir").keep();
909 crate::preferences::set_path_override(scratch.join("preferences"));
910 crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
911 let config = SshConfigFile {
912 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
913 path: scratch.join("test_config"),
914 crlf: false,
915 bom: false,
916 };
917 let mut app = App::new(config);
918 let pub_path = scratch.join("id_test.pub");
921 std::fs::write(
922 &pub_path,
923 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
924 )
925 .unwrap();
926 app.keys.list.push(crate::ssh_keys::SshKeyInfo {
927 name: "id_test".into(),
928 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
929 key_type: "ED25519".into(),
930 bits: "256".into(),
931 fingerprint: String::new(),
932 comment: "test@host".into(),
933 linked_hosts: vec![],
934 bishop_art: String::new(),
935 strength_score: 95,
936 encrypted: false,
937 agent_loaded: false,
938 is_certificate: false,
939 mtime_ts: None,
940 });
941 (app, scratch)
942 }
943
944 fn k(code: KeyCode) -> KeyEvent {
945 KeyEvent::new(code, KeyModifiers::NONE)
946 }
947
948 #[test]
949 fn n_returns_to_picker_with_key_index_preserved() {
950 let (mut app, _scratch) = make_app();
951 app.keys.push.committed = vec!["h1".into()];
952 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
953 let (tx, _rx) = mpsc::channel();
954 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
955 match app.screen {
956 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
957 ref other => panic!("expected KeyPushPicker, got {:?}", other),
958 }
959 assert!(
960 app.keys.push.committed.is_empty(),
961 "n should drop the frozen selection"
962 );
963 }
964
965 #[test]
966 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
967 let (mut app, _scratch) = make_app();
968 app.keys.push.committed = vec!["h1".into()];
969 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
970 let (tx, _rx) = mpsc::channel();
971 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
972 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
973 }
974
975 #[test]
976 fn start_rejects_when_a_previous_run_is_still_in_flight() {
977 let (mut app, _scratch) = make_app();
978 app.keys.push.expected_count = 2;
979 app.keys.push.results.push(crate::key_push::KeyPushResult {
980 alias: "h1".into(),
981 outcome: crate::key_push::KeyPushOutcome::Appended,
982 });
983 let (tx, _rx) = mpsc::channel();
984 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
985 assert_eq!(
986 app.keys.push.expected_count, 2,
987 "guard must not reset in-flight state"
988 );
989 let toast = app.status_center.toast.as_ref().expect("toast set");
990 assert!(
991 toast.text.contains("already running"),
992 "expected 'already running' warning, got: {}",
993 toast.text
994 );
995 }
996
997 #[test]
998 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
999 let (mut app, _scratch) = make_app();
1000 let (tx, _rx) = mpsc::channel();
1001 start_key_push(&mut app, 0, Vec::new(), &tx);
1002 assert_eq!(app.keys.push.expected_count, 0);
1003 assert!(app.keys.push.worker.is_none());
1004 let toast = app.status_center.toast.as_ref().expect("toast set");
1005 assert!(toast.is_error());
1006 }
1007
1008 #[test]
1009 fn start_rejects_certificate_key() {
1010 let (mut app, _scratch) = make_app();
1011 app.keys.list[0].is_certificate = true;
1012 let (tx, _rx) = mpsc::channel();
1013 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1014 assert_eq!(app.keys.push.expected_count, 0);
1015 assert!(app.keys.push.worker.is_none());
1016 let toast = app.status_center.toast.as_ref().expect("toast set");
1017 assert!(toast.is_error());
1018 assert!(toast.text.contains("Certificates"));
1019 }
1020
1021 #[test]
1022 fn start_rejects_missing_pubkey_file() {
1023 let (mut app, _scratch) = make_app();
1024 app.keys.list[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1025 let (tx, _rx) = mpsc::channel();
1026 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1027 assert_eq!(app.keys.push.expected_count, 0);
1028 let toast = app.status_center.toast.as_ref().expect("toast set");
1029 assert!(toast.is_error());
1030 }
1031
1032 #[test]
1033 fn start_rejects_invalid_pubkey_content() {
1034 let (mut app, scratch) = make_app();
1035 let pub_path = scratch.join("id_bad.pub");
1038 std::fs::write(
1039 &pub_path,
1040 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1041 )
1042 .unwrap();
1043 app.keys.list[0].display_path = pub_path.with_extension("").to_string_lossy().into_owned();
1044 app.keys.list[0].name = "id_bad".into();
1045 let (tx, _rx) = mpsc::channel();
1046 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1047 assert_eq!(app.keys.push.expected_count, 0);
1048 assert!(app.keys.push.worker.is_none());
1049 let toast = app.status_center.toast.as_ref().expect("toast set");
1050 assert!(toast.is_error());
1051 assert!(toast.text.contains("validation"));
1052 }
1053}