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