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 .queue_action(crate::app::ContainerActionRequest {
706 alias,
707 askpass,
708 runtime,
709 container_id,
710 container_name,
711 action,
712 });
713}
714
715pub(super) fn handle_key_push_key(
719 app: &mut App,
720 key: KeyEvent,
721 events_tx: &mpsc::Sender<AppEvent>,
722) {
723 match route_confirm_key(key) {
724 ConfirmAction::Yes => {
725 let key_index = match &app.screen {
726 Screen::ConfirmKeyPush { key_index } => *key_index,
727 _ => return,
728 };
729 let aliases = std::mem::take(&mut app.keys.push.committed);
730 app.set_screen(Screen::HostList);
731 start_key_push(app, key_index, aliases, events_tx);
732 }
733 ConfirmAction::No => {
734 let key_index = match &app.screen {
737 Screen::ConfirmKeyPush { key_index } => *key_index,
738 _ => return,
739 };
740 app.keys.push.committed.clear();
741 app.set_screen(Screen::KeyPushPicker { key_index });
742 }
743 ConfirmAction::Ignored => {}
744 }
745}
746
747fn start_key_push(
754 app: &mut App,
755 key_index: usize,
756 aliases: Vec<String>,
757 events_tx: &mpsc::Sender<AppEvent>,
758) {
759 if app.keys.push.expected_count > 0
765 || app
766 .keys
767 .push
768 .worker
769 .as_ref()
770 .is_some_and(|h| !h.is_finished())
771 {
772 log::debug!(
773 "[purple] key_push: rejected second push, run already in progress ({} of {})",
774 app.keys.push.results.len(),
775 app.keys.push.expected_count
776 );
777 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
778 return;
779 }
780 if aliases.is_empty() {
781 log::debug!("[purple] key_push: rejected, no aliases committed");
782 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
783 return;
784 }
785 let Some(key_info) = app.keys.list.get(key_index).cloned() else {
786 return;
787 };
788 if key_info.is_certificate {
789 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
790 return;
791 }
792 let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
793 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
794 Ok(s) => s,
795 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
796 log::warn!(
797 "[purple] key_push: pubkey too large path={} bytes={}",
798 pub_path.display(),
799 n
800 );
801 app.notify_error(crate::messages::key_push_pubkey_too_large(
802 &key_info.name,
803 n,
804 ));
805 return;
806 }
807 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
808 log::warn!(
809 "[purple] key_push: pubkey not a regular file path={}",
810 pub_path.display()
811 );
812 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
813 return;
814 }
815 Err(_) => {
816 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
820 return;
821 }
822 };
823 let pubkey = match crate::key_push::validate_pubkey(&raw) {
824 Ok(s) => s,
825 Err(err) => {
826 let detail = match &err {
827 crate::key_push::PubkeyValidationError::Empty => "file is empty",
828 crate::key_push::PubkeyValidationError::MultiLine => {
829 "must be a single line; multi-line input is rejected"
830 }
831 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
832 "key algorithm not allowed for static push"
833 }
834 crate::key_push::PubkeyValidationError::MalformedBase64 => {
835 "base64 key body did not parse"
836 }
837 _ => "unexpected format",
838 };
839 log::warn!(
840 "[purple] key_push: invalid pubkey path={} err={:?}",
841 pub_path.display(),
842 err
843 );
844 app.notify_error(crate::messages::key_push_invalid_pubkey(
845 &key_info.name,
846 detail,
847 ));
848 return;
849 }
850 };
851
852 let (run_id, cancel) = app.keys.push.start_run(aliases.len());
854
855 app.notify_progress(crate::messages::key_push_in_progress(
856 &key_info.name,
857 aliases.len(),
858 ));
859
860 let config_path = app.hosts_state.ssh_config.path.clone();
861 let tx = events_tx.clone();
862 let pubkey_payload = pubkey;
863 let handle = std::thread::Builder::new()
864 .name("key-push".into())
865 .spawn(move || {
866 for alias in aliases {
867 if cancel.load(Ordering::Relaxed) {
868 break;
869 }
870 let outcome =
871 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
872 let _ = tx.send(AppEvent::KeyPushResult {
873 run_id,
874 result: crate::key_push::KeyPushResult { alias, outcome },
875 });
876 }
877 });
878 match handle {
879 Ok(h) => {
880 app.keys.push.worker = Some(h);
881 }
882 Err(e) => {
883 log::error!("[purple] key_push: failed to spawn worker: {}", e);
884 app.status_center.clear_sticky_status();
888 app.notify_error(crate::messages::key_push_thread_spawn_failed());
889 app.keys.push.clear_inflight_state();
890 }
891 }
892}
893
894#[cfg(test)]
895mod key_push_confirm_tests {
896 use super::*;
903 use crate::ssh_config::model::SshConfigFile;
904 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
905
906 fn make_app() -> (App, std::path::PathBuf) {
907 let scratch = tempfile::tempdir().expect("tempdir").keep();
908 crate::preferences::set_path_override(scratch.join("preferences"));
909 crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
910 let config = SshConfigFile {
911 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
912 path: scratch.join("test_config"),
913 crlf: false,
914 bom: false,
915 };
916 let mut app = App::new(config);
917 let pub_path = scratch.join("id_test.pub");
920 std::fs::write(
921 &pub_path,
922 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
923 )
924 .unwrap();
925 app.keys.list.push(crate::ssh_keys::SshKeyInfo {
926 name: "id_test".into(),
927 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
928 key_type: "ED25519".into(),
929 bits: "256".into(),
930 fingerprint: String::new(),
931 comment: "test@host".into(),
932 linked_hosts: vec![],
933 bishop_art: String::new(),
934 strength_score: 95,
935 encrypted: false,
936 agent_loaded: false,
937 is_certificate: false,
938 mtime_ts: None,
939 });
940 (app, scratch)
941 }
942
943 fn k(code: KeyCode) -> KeyEvent {
944 KeyEvent::new(code, KeyModifiers::NONE)
945 }
946
947 #[test]
948 fn n_returns_to_picker_with_key_index_preserved() {
949 let (mut app, _scratch) = make_app();
950 app.keys.push.committed = vec!["h1".into()];
951 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
952 let (tx, _rx) = mpsc::channel();
953 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
954 match app.screen {
955 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
956 ref other => panic!("expected KeyPushPicker, got {:?}", other),
957 }
958 assert!(
959 app.keys.push.committed.is_empty(),
960 "n should drop the frozen selection"
961 );
962 }
963
964 #[test]
965 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
966 let (mut app, _scratch) = make_app();
967 app.keys.push.committed = vec!["h1".into()];
968 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
969 let (tx, _rx) = mpsc::channel();
970 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
971 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
972 }
973
974 #[test]
975 fn start_rejects_when_a_previous_run_is_still_in_flight() {
976 let (mut app, _scratch) = make_app();
977 app.keys.push.expected_count = 2;
978 app.keys.push.results.push(crate::key_push::KeyPushResult {
979 alias: "h1".into(),
980 outcome: crate::key_push::KeyPushOutcome::Appended,
981 });
982 let (tx, _rx) = mpsc::channel();
983 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
984 assert_eq!(
985 app.keys.push.expected_count, 2,
986 "guard must not reset in-flight state"
987 );
988 let toast = app.status_center.toast.as_ref().expect("toast set");
989 assert!(
990 toast.text.contains("already running"),
991 "expected 'already running' warning, got: {}",
992 toast.text
993 );
994 }
995
996 #[test]
997 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
998 let (mut app, _scratch) = make_app();
999 let (tx, _rx) = mpsc::channel();
1000 start_key_push(&mut app, 0, Vec::new(), &tx);
1001 assert_eq!(app.keys.push.expected_count, 0);
1002 assert!(app.keys.push.worker.is_none());
1003 let toast = app.status_center.toast.as_ref().expect("toast set");
1004 assert!(toast.is_error());
1005 }
1006
1007 #[test]
1008 fn start_rejects_certificate_key() {
1009 let (mut app, _scratch) = make_app();
1010 app.keys.list[0].is_certificate = true;
1011 let (tx, _rx) = mpsc::channel();
1012 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1013 assert_eq!(app.keys.push.expected_count, 0);
1014 assert!(app.keys.push.worker.is_none());
1015 let toast = app.status_center.toast.as_ref().expect("toast set");
1016 assert!(toast.is_error());
1017 assert!(toast.text.contains("Certificates"));
1018 }
1019
1020 #[test]
1021 fn start_rejects_missing_pubkey_file() {
1022 let (mut app, _scratch) = make_app();
1023 app.keys.list[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
1024 let (tx, _rx) = mpsc::channel();
1025 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1026 assert_eq!(app.keys.push.expected_count, 0);
1027 let toast = app.status_center.toast.as_ref().expect("toast set");
1028 assert!(toast.is_error());
1029 }
1030
1031 #[test]
1032 fn start_rejects_invalid_pubkey_content() {
1033 let (mut app, scratch) = make_app();
1034 let pub_path = scratch.join("id_bad.pub");
1037 std::fs::write(
1038 &pub_path,
1039 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1040 )
1041 .unwrap();
1042 app.keys.list[0].display_path = pub_path.with_extension("").to_string_lossy().into_owned();
1043 app.keys.list[0].name = "id_bad".into();
1044 let (tx, _rx) = mpsc::channel();
1045 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1046 assert_eq!(app.keys.push.expected_count, 0);
1047 assert!(app.keys.push.worker.is_none());
1048 let toast = app.status_center.toast.as_ref().expect("toast set");
1049 assert!(toast.is_error());
1050 assert!(toast.text.contains("validation"));
1051 }
1052}