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 let _ = app.vault.finalize_signing_run();
449 app.notify_error(crate::messages::vault_spawn_failed(&e));
450 }
451 }
452}
453
454pub(super) fn remove_in_flight(
455 set: &std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
456 alias: &str,
457) {
458 let mut guard = match set.lock() {
462 Ok(g) => g,
463 Err(p) => p.into_inner(),
464 };
465 guard.remove(alias);
466}
467
468pub(super) fn handle_host_key_reset_key(app: &mut App, key: KeyEvent) {
469 let Screen::ConfirmHostKeyReset {
470 alias,
471 hostname,
472 known_hosts_path,
473 askpass,
474 } = &app.screen
475 else {
476 return;
477 };
478 let alias = alias.clone();
479 let hostname = hostname.clone();
480 let known_hosts_path = known_hosts_path.clone();
481 let askpass = askpass.clone();
482 match route_confirm_key(key) {
485 ConfirmAction::Yes => {
486 let output = std::process::Command::new("ssh-keygen")
487 .arg("-R")
488 .arg(&hostname)
489 .arg("-f")
490 .arg(&known_hosts_path)
491 .output();
492
493 match output {
494 Ok(result) if result.status.success() => {
495 app.notify(crate::messages::removed_host_key(&hostname));
496 if app.demo_mode {
497 app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
498 } else {
499 app.ui.queue_connect(alias, askpass);
500 }
501 }
502 Ok(result) => {
503 let stderr = String::from_utf8_lossy(&result.stderr);
504 app.notify_error(crate::messages::host_key_remove_failed(stderr.trim()));
505 }
506 Err(e) => {
507 app.notify_error(crate::messages::ssh_keygen_failed(&e));
508 }
509 }
510 app.set_screen(Screen::HostList);
511 }
512 ConfirmAction::No => {
513 app.set_screen(Screen::HostList);
514 }
515 ConfirmAction::Ignored => {}
516 }
517}
518
519pub(super) fn handle_container_restart_key(app: &mut App, key: KeyEvent) {
524 let Screen::ConfirmContainerRestart {
525 alias,
526 container_id,
527 container_name,
528 ..
529 } = &app.screen
530 else {
531 return;
532 };
533 let alias = alias.clone();
534 let container_id = container_id.clone();
535 let container_name = container_name.clone();
536 match route_confirm_key(key) {
537 ConfirmAction::Yes => {
538 queue_container_action(
539 app,
540 alias,
541 container_id,
542 container_name,
543 crate::containers::ContainerAction::Restart,
544 );
545 app.set_screen(Screen::HostList);
546 }
547 ConfirmAction::No => {
548 app.set_screen(Screen::HostList);
549 }
550 ConfirmAction::Ignored => {}
551 }
552}
553
554pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
558 let Screen::ConfirmContainerStop {
559 alias,
560 container_id,
561 container_name,
562 ..
563 } = &app.screen
564 else {
565 return;
566 };
567 let alias = alias.clone();
568 let container_id = container_id.clone();
569 let container_name = container_name.clone();
570 match route_confirm_key(key) {
571 ConfirmAction::Yes => {
572 queue_container_action(
573 app,
574 alias,
575 container_id,
576 container_name,
577 crate::containers::ContainerAction::Stop,
578 );
579 app.set_screen(Screen::HostList);
580 }
581 ConfirmAction::No => {
582 app.set_screen(Screen::HostList);
583 }
584 ConfirmAction::Ignored => {}
585 }
586}
587
588pub(super) fn handle_stack_restart_key(app: &mut App, key: KeyEvent) {
593 let Screen::ConfirmStackRestart { alias, members, .. } = &app.screen else {
594 return;
595 };
596 let alias = alias.clone();
597 let members = members.clone();
598 match route_confirm_key(key) {
599 ConfirmAction::Yes => {
600 for m in members {
601 queue_container_action(
602 app,
603 alias.clone(),
604 m.container_id,
605 m.container_name,
606 crate::containers::ContainerAction::Restart,
607 );
608 }
609 app.set_screen(Screen::HostList);
610 }
611 ConfirmAction::No => {
612 app.set_screen(Screen::HostList);
613 }
614 ConfirmAction::Ignored => {}
615 }
616}
617
618pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
623 let Screen::ConfirmHostRestartAll { alias, members } = &app.screen else {
624 return;
625 };
626 let alias = alias.clone();
627 let members = members.clone();
628 match route_confirm_key(key) {
629 ConfirmAction::Yes => {
630 for m in members {
631 queue_container_action(
632 app,
633 alias.clone(),
634 m.container_id,
635 m.container_name,
636 crate::containers::ContainerAction::Restart,
637 );
638 }
639 app.set_screen(Screen::HostList);
640 }
641 ConfirmAction::No => {
642 app.set_screen(Screen::HostList);
643 }
644 ConfirmAction::Ignored => {}
645 }
646}
647
648pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
651 let Screen::ConfirmHostStopAll { alias, members } = &app.screen else {
652 return;
653 };
654 let alias = alias.clone();
655 let members = members.clone();
656 match route_confirm_key(key) {
657 ConfirmAction::Yes => {
658 for m in members {
659 queue_container_action(
660 app,
661 alias.clone(),
662 m.container_id,
663 m.container_name,
664 crate::containers::ContainerAction::Stop,
665 );
666 }
667 app.set_screen(Screen::HostList);
668 }
669 ConfirmAction::No => {
670 app.set_screen(Screen::HostList);
671 }
672 ConfirmAction::Ignored => {}
673 }
674}
675
676fn queue_container_action(
677 app: &mut App,
678 alias: String,
679 container_id: String,
680 container_name: String,
681 action: crate::containers::ContainerAction,
682) {
683 let Some(entry) = app.container_state.cache.get(&alias) else {
684 log::debug!(
685 "[purple] container_action: queue aborted, no cache for alias={}",
686 alias
687 );
688 return;
689 };
690 let runtime = entry.runtime;
691 let askpass = app
692 .hosts_state
693 .list
694 .iter()
695 .find(|h| h.alias == alias)
696 .and_then(|h| h.askpass.clone());
697 log::info!(
698 "[purple] container_action queued: alias={} id={} action={:?}",
699 alias,
700 container_id,
701 action
702 );
703 app.container_state
704 .queue_action(crate::app::ContainerActionRequest {
705 alias,
706 askpass,
707 runtime,
708 container_id,
709 container_name,
710 action,
711 });
712}
713
714pub(super) fn handle_key_push_key(
718 app: &mut App,
719 key: KeyEvent,
720 events_tx: &mpsc::Sender<AppEvent>,
721) {
722 match route_confirm_key(key) {
723 ConfirmAction::Yes => {
724 let key_index = match &app.screen {
725 Screen::ConfirmKeyPush { key_index } => *key_index,
726 _ => return,
727 };
728 let aliases = std::mem::take(&mut app.keys.push.committed);
729 app.set_screen(Screen::HostList);
730 start_key_push(app, key_index, aliases, events_tx);
731 }
732 ConfirmAction::No => {
733 let key_index = match &app.screen {
736 Screen::ConfirmKeyPush { key_index } => *key_index,
737 _ => return,
738 };
739 app.keys.push.committed.clear();
740 app.set_screen(Screen::KeyPushPicker { key_index });
741 }
742 ConfirmAction::Ignored => {}
743 }
744}
745
746fn start_key_push(
753 app: &mut App,
754 key_index: usize,
755 aliases: Vec<String>,
756 events_tx: &mpsc::Sender<AppEvent>,
757) {
758 if app.keys.push.expected_count > 0
764 || app
765 .keys
766 .push
767 .worker
768 .as_ref()
769 .is_some_and(|h| !h.is_finished())
770 {
771 log::debug!(
772 "[purple] key_push: rejected second push, run already in progress ({} of {})",
773 app.keys.push.results.len(),
774 app.keys.push.expected_count
775 );
776 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
777 return;
778 }
779 if aliases.is_empty() {
780 log::debug!("[purple] key_push: rejected, no aliases committed");
781 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
782 return;
783 }
784 let Some(key_info) = app.keys.list.get(key_index).cloned() else {
785 return;
786 };
787 if key_info.is_certificate {
788 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
789 return;
790 }
791 let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
792 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
793 Ok(s) => s,
794 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
795 log::warn!(
796 "[purple] key_push: pubkey too large path={} bytes={}",
797 pub_path.display(),
798 n
799 );
800 app.notify_error(crate::messages::key_push_pubkey_too_large(
801 &key_info.name,
802 n,
803 ));
804 return;
805 }
806 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
807 log::warn!(
808 "[purple] key_push: pubkey not a regular file path={}",
809 pub_path.display()
810 );
811 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
812 return;
813 }
814 Err(_) => {
815 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
819 return;
820 }
821 };
822 let pubkey = match crate::key_push::validate_pubkey(&raw) {
823 Ok(s) => s,
824 Err(err) => {
825 let detail = match &err {
826 crate::key_push::PubkeyValidationError::Empty => "file is empty",
827 crate::key_push::PubkeyValidationError::MultiLine => {
828 "must be a single line; multi-line input is rejected"
829 }
830 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
831 "key algorithm not allowed for static push"
832 }
833 crate::key_push::PubkeyValidationError::MalformedBase64 => {
834 "base64 key body did not parse"
835 }
836 _ => "unexpected format",
837 };
838 log::warn!(
839 "[purple] key_push: invalid pubkey path={} err={:?}",
840 pub_path.display(),
841 err
842 );
843 app.notify_error(crate::messages::key_push_invalid_pubkey(
844 &key_info.name,
845 detail,
846 ));
847 return;
848 }
849 };
850
851 let (run_id, cancel) = app.keys.push.start_run(aliases.len());
853
854 app.notify_progress(crate::messages::key_push_in_progress(
855 &key_info.name,
856 aliases.len(),
857 ));
858
859 let config_path = app.hosts_state.ssh_config.path.clone();
860 let tx = events_tx.clone();
861 let pubkey_payload = pubkey;
862 let handle = std::thread::Builder::new()
863 .name("key-push".into())
864 .spawn(move || {
865 for alias in aliases {
866 if cancel.load(Ordering::Relaxed) {
867 break;
868 }
869 let outcome =
870 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
871 let _ = tx.send(AppEvent::KeyPushResult {
872 run_id,
873 result: crate::key_push::KeyPushResult { alias, outcome },
874 });
875 }
876 });
877 match handle {
878 Ok(h) => {
879 app.keys.push.worker = Some(h);
880 }
881 Err(e) => {
882 log::error!("[purple] key_push: failed to spawn worker: {}", e);
883 app.status_center.clear_sticky_status();
887 app.notify_error(crate::messages::key_push_thread_spawn_failed());
888 app.keys.push.clear_inflight_state();
889 }
890 }
891}
892
893#[cfg(test)]
894mod key_push_confirm_tests {
895 use super::*;
902 use crate::ssh_config::model::SshConfigFile;
903 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
904
905 fn make_app() -> (App, std::path::PathBuf) {
906 let scratch = tempfile::tempdir().expect("tempdir").keep();
907 crate::preferences::set_path_override(scratch.join("preferences"));
908 crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
909 let config = SshConfigFile {
910 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
911 path: scratch.join("test_config"),
912 crlf: false,
913 bom: false,
914 };
915 let mut app = App::new(config);
916 let pub_path = scratch.join("id_test.pub");
919 std::fs::write(
920 &pub_path,
921 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
922 )
923 .unwrap();
924 app.keys.list.push(crate::ssh_keys::SshKeyInfo {
925 name: "id_test".into(),
926 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
927 key_type: "ED25519".into(),
928 bits: "256".into(),
929 fingerprint: String::new(),
930 comment: "test@host".into(),
931 linked_hosts: vec![],
932 bishop_art: String::new(),
933 strength_score: 95,
934 encrypted: false,
935 agent_loaded: false,
936 is_certificate: false,
937 mtime_ts: None,
938 });
939 (app, scratch)
940 }
941
942 fn k(code: KeyCode) -> KeyEvent {
943 KeyEvent::new(code, KeyModifiers::NONE)
944 }
945
946 #[test]
947 fn n_returns_to_picker_with_key_index_preserved() {
948 let (mut app, _scratch) = make_app();
949 app.keys.push.committed = vec!["h1".into()];
950 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
951 let (tx, _rx) = mpsc::channel();
952 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
953 match app.screen {
954 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
955 ref other => panic!("expected KeyPushPicker, got {:?}", other),
956 }
957 assert!(
958 app.keys.push.committed.is_empty(),
959 "n should drop the frozen selection"
960 );
961 }
962
963 #[test]
964 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
965 let (mut app, _scratch) = make_app();
966 app.keys.push.committed = vec!["h1".into()];
967 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
968 let (tx, _rx) = mpsc::channel();
969 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
970 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
971 }
972
973 #[test]
974 fn start_rejects_when_a_previous_run_is_still_in_flight() {
975 let (mut app, _scratch) = make_app();
976 app.keys.push.expected_count = 2;
977 app.keys.push.results.push(crate::key_push::KeyPushResult {
978 alias: "h1".into(),
979 outcome: crate::key_push::KeyPushOutcome::Appended,
980 });
981 let (tx, _rx) = mpsc::channel();
982 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
983 assert_eq!(
984 app.keys.push.expected_count, 2,
985 "guard must not reset in-flight state"
986 );
987 let toast = app.status_center.toast.as_ref().expect("toast set");
988 assert!(
989 toast.text.contains("already running"),
990 "expected 'already running' warning, got: {}",
991 toast.text
992 );
993 }
994
995 #[test]
996 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
997 let (mut app, _scratch) = make_app();
998 let (tx, _rx) = mpsc::channel();
999 start_key_push(&mut app, 0, Vec::new(), &tx);
1000 assert_eq!(app.keys.push.expected_count, 0);
1001 assert!(app.keys.push.worker.is_none());
1002 let toast = app.status_center.toast.as_ref().expect("toast set");
1003 assert!(toast.is_error());
1004 }
1005
1006 #[test]
1007 fn start_rejects_certificate_key() {
1008 let (mut app, _scratch) = make_app();
1009 app.keys.list[0].is_certificate = true;
1010 let (tx, _rx) = mpsc::channel();
1011 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1012 assert_eq!(app.keys.push.expected_count, 0);
1013 assert!(app.keys.push.worker.is_none());
1014 let toast = app.status_center.toast.as_ref().expect("toast set");
1015 assert!(toast.is_error());
1016 assert!(toast.text.contains("Certificates"));
1017 }
1018
1019 #[test]
1020 fn start_rejects_missing_pubkey_file() {
1021 let (mut app, _scratch) = make_app();
1022 app.keys.list[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1023 let (tx, _rx) = mpsc::channel();
1024 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1025 assert_eq!(app.keys.push.expected_count, 0);
1026 let toast = app.status_center.toast.as_ref().expect("toast set");
1027 assert!(toast.is_error());
1028 }
1029
1030 #[test]
1031 fn start_rejects_invalid_pubkey_content() {
1032 let (mut app, scratch) = make_app();
1033 let pub_path = scratch.join("id_bad.pub");
1036 std::fs::write(
1037 &pub_path,
1038 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1039 )
1040 .unwrap();
1041 app.keys.list[0].display_path = pub_path.with_extension("").to_string_lossy().into_owned();
1042 app.keys.list[0].name = "id_bad".into();
1043 let (tx, _rx) = mpsc::channel();
1044 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1045 assert_eq!(app.keys.push.expected_count, 0);
1046 assert!(app.keys.push.worker.is_none());
1047 let toast = app.status_center.toast.as_ref().expect("toast set");
1048 assert!(toast.is_error());
1049 assert!(toast.text.contains("validation"));
1050 }
1051}