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 app.hosts_state.ssh_config_mut(),
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.set_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.set_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_mut().delete_host(alias);
126 }
127 if let Err(e) = app.hosts_state.ssh_config().write() {
128 app.hosts_state.set_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.clear_undo();
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_mut().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)) = app
196 .hosts_state
197 .ssh_config_mut()
198 .delete_host_undoable(&alias)
199 {
200 if let Err(e) = app.hosts_state.ssh_config().write() {
201 app.hosts_state
203 .ssh_config_mut()
204 .insert_host_at(element, position);
205 app.notify_error(crate::messages::failed_to_save(&e));
206 } else {
207 if let Some(mut tunnel) = app.tunnels.active_remove(&alias) {
209 let _ = tunnel.child.kill();
210 let _ = tunnel.child.wait();
211 }
212 let mut cert_cleanup_warning: Option<String> = None;
217 if !crate::demo_flag::is_demo() {
218 if let Ok(cert_path) = crate::vault_ssh::cert_path_for(&alias) {
219 match std::fs::remove_file(&cert_path) {
220 Ok(()) => {}
221 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
222 Err(e) => {
223 cert_cleanup_warning =
224 Some(crate::messages::cert_cleanup_warning(
225 &cert_path.display(),
226 &e,
227 ));
228 }
229 }
230 }
231 }
232 app.hosts_state
233 .undo_stack_mut()
234 .push(crate::app::DeletedHost { element, position });
235 if app.hosts_state.undo_stack().len() > 50 {
236 app.hosts_state.undo_stack_mut().remove(0);
237 }
238 app.update_last_modified();
239 app.reload_hosts();
240 if let Some(warning) = cert_cleanup_warning {
241 app.notify_error(warning);
242 } else {
243 app.notify(crate::messages::goodbye_host(&alias));
244 }
245 }
246 } else {
247 app.notify_warning(crate::messages::host_not_found(&alias));
248 }
249 app.set_screen(Screen::HostList);
250 }
251 ConfirmAction::No => {
252 app.set_screen(Screen::HostList);
253 }
254 ConfirmAction::Ignored => {}
255 }
256}
257
258pub(super) fn handle_vault_sign_key(
259 app: &mut App,
260 key: KeyEvent,
261 events_tx: &mpsc::Sender<AppEvent>,
262) {
263 match route_confirm_key(key) {
270 ConfirmAction::Yes => {
271 let signable = if let Screen::ConfirmVaultSign { signable } = &app.screen {
274 signable.clone()
275 } else {
276 return;
277 };
278 app.set_screen(Screen::HostList);
279 start_vault_bulk_sign(app, signable, events_tx);
280 }
281 ConfirmAction::No => {
282 app.set_screen(Screen::HostList);
283 }
284 ConfirmAction::Ignored => {}
285 }
286}
287
288fn start_vault_bulk_sign(
291 app: &mut App,
292 signable: Vec<crate::vault_ssh::VaultSignTarget>,
293 events_tx: &mpsc::Sender<AppEvent>,
294) {
295 let total = signable.len();
296 if total == 0 {
297 return;
298 }
299 app.notify_progress(crate::messages::vault_signing_progress(
300 crate::animation::SPINNER_FRAMES[0],
301 0,
302 total,
303 "",
304 ));
305
306 let cancel = Arc::new(AtomicBool::new(false));
307 app.vault.set_signing_cancel(cancel.clone());
308
309 let in_flight = app.vault.sign_in_flight().clone();
310 let tx = events_tx.clone();
311 let spawn_result = std::thread::Builder::new()
312 .name("vault-bulk-sign".into())
313 .spawn(move || {
314 let mut signed = 0u32;
315 let mut failed = 0u32;
316 let mut skipped = 0u32;
317 let mut consecutive_failures = 0usize;
318 let mut first_error: Option<String> = None;
319 let mut aborted_message: Option<String> = None;
320
321 for (idx, target) in signable.iter().enumerate() {
322 let crate::vault_ssh::VaultSignTarget {
323 alias,
324 role,
325 certificate_file: cert_file,
326 pubkey,
327 vault_addr,
328 } = target;
329 if cancel.load(Ordering::Relaxed) {
330 break;
331 }
332 let done = idx + 1;
333
334 {
337 let mut set = match in_flight.lock() {
342 Ok(g) => g,
343 Err(p) => p.into_inner(),
344 };
345 if !set.insert(alias.clone()) {
346 skipped += 1;
347 let _ = tx.send(AppEvent::VaultSignProgress {
348 alias: alias.clone(),
349 done,
350 total,
351 });
352 continue;
353 }
354 }
355
356 let _ = tx.send(AppEvent::VaultSignProgress {
357 alias: alias.clone(),
358 done,
359 total,
360 });
361
362 let cert_path = match crate::vault_ssh::resolve_cert_path(alias, cert_file) {
363 Ok(p) => p,
364 Err(e) => {
365 failed += 1;
366 consecutive_failures += 1;
367 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&e.to_string());
368 if first_error.is_none() {
369 first_error = Some(scrubbed);
370 }
371 remove_in_flight(&in_flight, alias);
372 if consecutive_failures >= 3 {
373 aborted_message = Some(crate::messages::vault_signing_aborted(
374 failed,
375 first_error.as_deref(),
376 ));
377 break;
378 }
379 continue;
380 }
381 };
382 let status = crate::vault_ssh::check_cert_validity(&cert_path);
383 if !crate::vault_ssh::needs_renewal(&status) {
384 skipped += 1;
385 consecutive_failures = 0;
386 remove_in_flight(&in_flight, alias);
387 continue;
388 }
389
390 let sign_result =
391 crate::vault_ssh::sign_certificate(role, pubkey, alias, vault_addr.as_deref());
392 remove_in_flight(&in_flight, alias);
396 match sign_result {
397 Ok(_) => {
398 let _ = tx.send(AppEvent::VaultSignResult {
399 alias: alias.clone(),
400 certificate_file: cert_file.clone(),
401 success: true,
402 message: String::new(),
403 });
404 signed += 1;
405 consecutive_failures = 0;
406 }
407 Err(e) => {
408 let raw = e.to_string();
409 let scrubbed = crate::vault_ssh::scrub_vault_stderr(&raw);
410 if first_error.is_none() {
411 first_error = Some(scrubbed.clone());
412 }
413 let _ = tx.send(AppEvent::VaultSignResult {
414 alias: alias.clone(),
415 certificate_file: cert_file.clone(),
416 success: false,
417 message: scrubbed,
418 });
419 failed += 1;
420 consecutive_failures += 1;
421 if consecutive_failures >= 3 {
422 aborted_message = Some(crate::messages::vault_signing_aborted(
423 failed,
424 first_error.as_deref(),
425 ));
426 break;
427 }
428 }
429 }
430 }
431
432 let cancelled = cancel.load(Ordering::Relaxed);
433 let _ = tx.send(AppEvent::VaultSignAllDone {
434 signed,
435 failed,
436 skipped,
437 cancelled,
438 aborted_message,
439 first_error,
440 });
441 });
442 match spawn_result {
443 Ok(handle) => {
444 log::info!("[purple] vault sign thread: spawned");
445 app.vault.set_sign_thread(handle);
446 }
447 Err(e) => {
448 log::warn!("[purple] vault sign thread: spawn failed: {}", e);
452 let _ = app.vault.finalize_signing_run();
453 app.notify_error(crate::messages::vault_spawn_failed(&e));
454 }
455 }
456}
457
458pub(super) fn remove_in_flight(
459 set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
460 alias: &str,
461) {
462 let mut guard = match set.lock() {
466 Ok(g) => g,
467 Err(p) => p.into_inner(),
468 };
469 guard.remove(alias);
470}
471
472pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
473 let Screen::ConfirmHostKeyReset {
474 alias,
475 hostname,
476 known_hosts_path,
477 askpass,
478 } = &app.screen
479 else {
480 return;
481 };
482 let alias = alias.clone();
483 let hostname = hostname.clone();
484 let known_hosts_path = known_hosts_path.clone();
485 let askpass = askpass.clone();
486 match route_confirm_key(key) {
489 ConfirmAction::Yes => {
490 let output = std::process::Command::new("ssh-keygen")
491 .arg("-R")
492 .arg(&hostname)
493 .arg("-f")
494 .arg(&known_hosts_path)
495 .output();
496
497 match output {
498 Ok(result) if result.status.success() => {
499 app.notify(crate::messages::removed_host_key(&hostname));
500 if app.demo_mode {
501 app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
502 } else {
503 app.ui.queue_connect(alias, askpass);
504 }
505 }
506 Ok(result) => {
507 let stderr = String::from_utf8_lossy(&result.stderr);
508 app.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
509 }
510 Err(e) => {
511 app.notify_error(crate::messages::ssh_keygen_failed(&e));
512 }
513 }
514 app.set_screen(Screen::HostList);
515 }
516 ConfirmAction::No => {
517 app.set_screen(Screen::HostList);
518 }
519 ConfirmAction::Ignored => {}
520 }
521}
522
523pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
528 let Screen::ConfirmContainerRestart {
529 alias,
530 container_id,
531 container_name,
532 ..
533 } = &app.screen
534 else {
535 return;
536 };
537 let alias = alias.clone();
538 let container_id = container_id.clone();
539 let container_name = container_name.clone();
540 match route_confirm_key(key) {
541 ConfirmAction::Yes => {
542 queue_container_action(
543 app,
544 alias,
545 container_id,
546 container_name,
547 crate::containers::ContainerAction::Restart,
548 );
549 app.set_screen(Screen::HostList);
550 }
551 ConfirmAction::No => {
552 app.set_screen(Screen::HostList);
553 }
554 ConfirmAction::Ignored => {}
555 }
556}
557
558pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
562 let Screen::ConfirmContainerStop {
563 alias,
564 container_id,
565 container_name,
566 ..
567 } = &app.screen
568 else {
569 return;
570 };
571 let alias = alias.clone();
572 let container_id = container_id.clone();
573 let container_name = container_name.clone();
574 match route_confirm_key(key) {
575 ConfirmAction::Yes => {
576 queue_container_action(
577 app,
578 alias,
579 container_id,
580 container_name,
581 crate::containers::ContainerAction::Stop,
582 );
583 app.set_screen(Screen::HostList);
584 }
585 ConfirmAction::No => {
586 app.set_screen(Screen::HostList);
587 }
588 ConfirmAction::Ignored => {}
589 }
590}
591
592pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
597 let Screen::ConfirmStackRestart { alias, members, .. } = &app.screen else {
598 return;
599 };
600 let alias = alias.clone();
601 let members = members.clone();
602 match route_confirm_key(key) {
603 ConfirmAction::Yes => {
604 for m in members {
605 queue_container_action(
606 app,
607 alias.clone(),
608 m.container_id,
609 m.container_name,
610 crate::containers::ContainerAction::Restart,
611 );
612 }
613 app.set_screen(Screen::HostList);
614 }
615 ConfirmAction::No => {
616 app.set_screen(Screen::HostList);
617 }
618 ConfirmAction::Ignored => {}
619 }
620}
621
622pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
627 let Screen::ConfirmHostRestartAll { alias, members } = &app.screen else {
628 return;
629 };
630 let alias = alias.clone();
631 let members = members.clone();
632 match route_confirm_key(key) {
633 ConfirmAction::Yes => {
634 for m in members {
635 queue_container_action(
636 app,
637 alias.clone(),
638 m.container_id,
639 m.container_name,
640 crate::containers::ContainerAction::Restart,
641 );
642 }
643 app.set_screen(Screen::HostList);
644 }
645 ConfirmAction::No => {
646 app.set_screen(Screen::HostList);
647 }
648 ConfirmAction::Ignored => {}
649 }
650}
651
652pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
655 let Screen::ConfirmHostStopAll { alias, members } = &app.screen else {
656 return;
657 };
658 let alias = alias.clone();
659 let members = members.clone();
660 match route_confirm_key(key) {
661 ConfirmAction::Yes => {
662 for m in members {
663 queue_container_action(
664 app,
665 alias.clone(),
666 m.container_id,
667 m.container_name,
668 crate::containers::ContainerAction::Stop,
669 );
670 }
671 app.set_screen(Screen::HostList);
672 }
673 ConfirmAction::No => {
674 app.set_screen(Screen::HostList);
675 }
676 ConfirmAction::Ignored => {}
677 }
678}
679
680fn queue_container_action(
681 app: &mut App,
682 alias: String,
683 container_id: String,
684 container_name: String,
685 action: crate::containers::ContainerAction,
686) {
687 let Some(entry) = app.container_state.cache_entry(&alias) else {
688 log::debug!(
689 "[purple] container_action: queue aborted, no cache for alias={}",
690 alias
691 );
692 return;
693 };
694 let runtime = entry.runtime;
695 let askpass = app
696 .hosts_state
697 .list()
698 .iter()
699 .find(|h| h.alias == alias)
700 .and_then(|h| h.askpass.clone());
701 log::info!(
702 "[purple] container_action queued: alias={} id={} action={:?}",
703 alias,
704 container_id,
705 action
706 );
707 app.container_state
708 .queue_action(crate::app::ContainerActionRequest {
709 alias,
710 askpass,
711 runtime,
712 container_id,
713 container_name,
714 action,
715 });
716}
717
718pub(super) fn handle_key_push_key(
722 app: &mut App,
723 key: KeyEvent,
724 events_tx: &mpsc::Sender<AppEvent>,
725) {
726 match route_confirm_key(key) {
727 ConfirmAction::Yes => {
728 let key_index = match &app.screen {
729 Screen::ConfirmKeyPush { key_index } => *key_index,
730 _ => return,
731 };
732 let aliases = std::mem::take(&mut app.keys.push_mut().committed);
733 app.set_screen(Screen::HostList);
734 start_key_push(app, key_index, aliases, events_tx);
735 }
736 ConfirmAction::No => {
737 let key_index = match &app.screen {
740 Screen::ConfirmKeyPush { key_index } => *key_index,
741 _ => return,
742 };
743 app.keys.push_mut().committed.clear();
744 app.set_screen(Screen::KeyPushPicker { key_index });
745 }
746 ConfirmAction::Ignored => {}
747 }
748}
749
750fn start_key_push(
757 app: &mut App,
758 key_index: usize,
759 aliases: Vec<String>,
760 events_tx: &mpsc::Sender<AppEvent>,
761) {
762 if app.keys.push().expected_count > 0
768 || app
769 .keys
770 .push()
771 .worker
772 .as_ref()
773 .is_some_and(|h| !h.is_finished())
774 {
775 log::debug!(
776 "[purple] key_push: rejected second push, run already in progress ({} of {})",
777 app.keys.push().results.len(),
778 app.keys.push().expected_count
779 );
780 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
781 return;
782 }
783 if aliases.is_empty() {
784 log::debug!("[purple] key_push: rejected, no aliases committed");
785 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
786 return;
787 }
788 let Some(key_info) = app.keys.list().get(key_index).cloned() else {
789 return;
790 };
791 if key_info.is_certificate {
792 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
793 return;
794 }
795 let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
796 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
797 Ok(s) => s,
798 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
799 log::warn!(
800 "[purple] key_push: pubkey too large path={} bytes={}",
801 pub_path.display(),
802 n
803 );
804 app.notify_error(crate::messages::key_push_pubkey_too_large(
805 &key_info.name,
806 n,
807 ));
808 return;
809 }
810 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
811 log::warn!(
812 "[purple] key_push: pubkey not a regular file path={}",
813 pub_path.display()
814 );
815 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
816 return;
817 }
818 Err(_) => {
819 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
823 return;
824 }
825 };
826 let pubkey = match crate::key_push::validate_pubkey(&raw) {
827 Ok(s) => s,
828 Err(err) => {
829 let detail = match &err {
830 crate::key_push::PubkeyValidationError::Empty => "file is empty",
831 crate::key_push::PubkeyValidationError::MultiLine => {
832 "must be a single line; multi-line input is rejected"
833 }
834 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
835 "key algorithm not allowed for static push"
836 }
837 crate::key_push::PubkeyValidationError::MalformedBase64 => {
838 "base64 key body did not parse"
839 }
840 _ => "unexpected format",
841 };
842 log::warn!(
843 "[purple] key_push: invalid pubkey path={} err={:?}",
844 pub_path.display(),
845 err
846 );
847 app.notify_error(crate::messages::key_push_invalid_pubkey(
848 &key_info.name,
849 detail,
850 ));
851 return;
852 }
853 };
854
855 let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
857
858 app.notify_progress(crate::messages::key_push_in_progress(
859 &key_info.name,
860 aliases.len(),
861 ));
862
863 let config_path = app.hosts_state.ssh_config().path.clone();
864 let tx = events_tx.clone();
865 let pubkey_payload = pubkey;
866 let handle = std::thread::Builder::new()
867 .name("key-push".into())
868 .spawn(move || {
869 for alias in aliases {
870 if cancel.load(Ordering::Relaxed) {
871 break;
872 }
873 let outcome =
874 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
875 let _ = tx.send(AppEvent::KeyPushResult {
876 run_id,
877 result: crate::key_push::KeyPushResult { alias, outcome },
878 });
879 }
880 });
881 match handle {
882 Ok(h) => {
883 app.keys.push_mut().worker = Some(h);
884 }
885 Err(e) => {
886 log::error!("[purple] key_push: failed to spawn worker: {}", e);
887 app.status_center.clear_sticky_status();
891 app.notify_error(crate::messages::key_push_thread_spawn_failed());
892 app.keys.push_mut().clear_inflight_state();
893 }
894 }
895}
896
897#[cfg(test)]
898mod key_push_confirm_tests {
899 use super::*;
906 use crate::ssh_config::model::SshConfigFile;
907 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
908
909 fn make_app() -> (App, std::path::PathBuf) {
910 let scratch = tempfile::tempdir().expect("tempdir").keep();
911 crate::preferences::set_path_override(scratch.join("preferences"));
912 crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
913 let config = SshConfigFile {
914 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
915 path: scratch.join("test_config"),
916 crlf: false,
917 bom: false,
918 };
919 let mut app = App::new(config);
920 let pub_path = scratch.join("id_test.pub");
923 std::fs::write(
924 &pub_path,
925 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
926 )
927 .unwrap();
928 app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
929 name: "id_test".into(),
930 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
931 key_type: "ED25519".into(),
932 bits: "256".into(),
933 fingerprint: String::new(),
934 comment: "test@host".into(),
935 linked_hosts: vec![],
936 bishop_art: String::new(),
937 strength_score: 95,
938 encrypted: false,
939 agent_loaded: false,
940 is_certificate: false,
941 mtime_ts: None,
942 });
943 (app, scratch)
944 }
945
946 fn k(code: KeyCode) -> KeyEvent {
947 KeyEvent::new(code, KeyModifiers::NONE)
948 }
949
950 #[test]
951 fn n_returns_to_picker_with_key_index_preserved() {
952 let (mut app, _scratch) = make_app();
953 app.keys.push_mut().committed = vec!["h1".into()];
954 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
955 let (tx, _rx) = mpsc::channel();
956 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
957 match app.screen {
958 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
959 ref other => panic!("expected KeyPushPicker, got {:?}", other),
960 }
961 assert!(
962 app.keys.push().committed.is_empty(),
963 "n should drop the frozen selection"
964 );
965 }
966
967 #[test]
968 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
969 let (mut app, _scratch) = make_app();
970 app.keys.push_mut().committed = vec!["h1".into()];
971 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
972 let (tx, _rx) = mpsc::channel();
973 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
974 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
975 }
976
977 #[test]
978 fn start_rejects_when_a_previous_run_is_still_in_flight() {
979 let (mut app, _scratch) = make_app();
980 app.keys.push_mut().expected_count = 2;
981 app.keys
982 .push_mut()
983 .results
984 .push(crate::key_push::KeyPushResult {
985 alias: "h1".into(),
986 outcome: crate::key_push::KeyPushOutcome::Appended,
987 });
988 let (tx, _rx) = mpsc::channel();
989 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
990 assert_eq!(
991 app.keys.push().expected_count,
992 2,
993 "guard must not reset in-flight state"
994 );
995 let toast = app.status_center.toast().expect("toast set");
996 assert!(
997 toast.text.contains("already running"),
998 "expected 'already running' warning, got: {}",
999 toast.text
1000 );
1001 }
1002
1003 #[test]
1004 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
1005 let (mut app, _scratch) = make_app();
1006 let (tx, _rx) = mpsc::channel();
1007 start_key_push(&mut app, 0, Vec::new(), &tx);
1008 assert_eq!(app.keys.push().expected_count, 0);
1009 assert!(app.keys.push().worker.is_none());
1010 let toast = app.status_center.toast().expect("toast set");
1011 assert!(toast.is_error());
1012 }
1013
1014 #[test]
1015 fn start_rejects_certificate_key() {
1016 let (mut app, _scratch) = make_app();
1017 app.keys.list_mut()[0].is_certificate = true;
1018 let (tx, _rx) = mpsc::channel();
1019 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1020 assert_eq!(app.keys.push().expected_count, 0);
1021 assert!(app.keys.push().worker.is_none());
1022 let toast = app.status_center.toast().expect("toast set");
1023 assert!(toast.is_error());
1024 assert!(toast.text.contains("Certificates"));
1025 }
1026
1027 #[test]
1028 fn start_rejects_missing_pubkey_file() {
1029 let (mut app, _scratch) = make_app();
1030 app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1031 let (tx, _rx) = mpsc::channel();
1032 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1033 assert_eq!(app.keys.push().expected_count, 0);
1034 let toast = app.status_center.toast().expect("toast set");
1035 assert!(toast.is_error());
1036 }
1037
1038 #[test]
1039 fn start_rejects_invalid_pubkey_content() {
1040 let (mut app, scratch) = make_app();
1041 let pub_path = scratch.join("id_bad.pub");
1044 std::fs::write(
1045 &pub_path,
1046 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1047 )
1048 .unwrap();
1049 app.keys.list_mut()[0].display_path =
1050 pub_path.with_extension("").to_string_lossy().into_owned();
1051 app.keys.list_mut()[0].name = "id_bad".into();
1052 let (tx, _rx) = mpsc::channel();
1053 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1054 assert_eq!(app.keys.push().expected_count, 0);
1055 assert!(app.keys.push().worker.is_none());
1056 let toast = app.status_center.toast().expect("toast set");
1057 assert!(toast.is_error());
1058 assert!(toast.text.contains("validation"));
1059 }
1060}