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
523struct ContainerConfirm {
526 alias: String,
527 targets: Vec<(String, String)>,
528 action: crate::containers::ContainerAction,
529}
530
531fn apply_container_confirm(app: &mut App, key: KeyEvent, confirm: ContainerConfirm) {
535 match route_confirm_key(key) {
536 ConfirmAction::Yes => {
537 for (container_id, container_name) in confirm.targets {
538 queue_container_action(
539 app,
540 confirm.alias.clone(),
541 container_id,
542 container_name,
543 confirm.action,
544 );
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_restart_key(app: &mut App, key: KeyEvent) {
557 let Screen::ConfirmContainerRestart {
558 alias,
559 container_id,
560 container_name,
561 ..
562 } = &app.screen
563 else {
564 return;
565 };
566 let confirm = ContainerConfirm {
567 alias: alias.clone(),
568 targets: vec![(container_id.clone(), container_name.clone())],
569 action: crate::containers::ContainerAction::Restart,
570 };
571 apply_container_confirm(app, key, confirm);
572}
573
574pub(super) fn handle_container_stop_key(app: &mut App, key: KeyEvent) {
576 let Screen::ConfirmContainerStop {
577 alias,
578 container_id,
579 container_name,
580 ..
581 } = &app.screen
582 else {
583 return;
584 };
585 let confirm = ContainerConfirm {
586 alias: alias.clone(),
587 targets: vec![(container_id.clone(), container_name.clone())],
588 action: crate::containers::ContainerAction::Stop,
589 };
590 apply_container_confirm(app, key, confirm);
591}
592
593pub(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 confirm = ContainerConfirm {
601 alias: alias.clone(),
602 targets: members
603 .iter()
604 .map(|m| (m.container_id.clone(), m.container_name.clone()))
605 .collect(),
606 action: crate::containers::ContainerAction::Restart,
607 };
608 apply_container_confirm(app, key, confirm);
609}
610
611pub(super) fn handle_host_restart_all_key(app: &mut App, key: KeyEvent) {
614 let Screen::ConfirmHostRestartAll { alias, members } = &app.screen else {
615 return;
616 };
617 let confirm = ContainerConfirm {
618 alias: alias.clone(),
619 targets: members
620 .iter()
621 .map(|m| (m.container_id.clone(), m.container_name.clone()))
622 .collect(),
623 action: crate::containers::ContainerAction::Restart,
624 };
625 apply_container_confirm(app, key, confirm);
626}
627
628pub(super) fn handle_host_stop_all_key(app: &mut App, key: KeyEvent) {
631 let Screen::ConfirmHostStopAll { alias, members } = &app.screen else {
632 return;
633 };
634 let confirm = ContainerConfirm {
635 alias: alias.clone(),
636 targets: members
637 .iter()
638 .map(|m| (m.container_id.clone(), m.container_name.clone()))
639 .collect(),
640 action: crate::containers::ContainerAction::Stop,
641 };
642 apply_container_confirm(app, key, confirm);
643}
644
645fn queue_container_action(
646 app: &mut App,
647 alias: String,
648 container_id: String,
649 container_name: String,
650 action: crate::containers::ContainerAction,
651) {
652 let Some(entry) = app.container_state.cache_entry(&alias) else {
653 log::debug!(
654 "[purple] container_action: queue aborted, no cache for alias={}",
655 alias
656 );
657 return;
658 };
659 let runtime = entry.runtime;
660 let askpass = app
661 .hosts_state
662 .list()
663 .iter()
664 .find(|h| h.alias == alias)
665 .and_then(|h| h.askpass.clone());
666 log::info!(
667 "[purple] container_action queued: alias={} id={} action={:?}",
668 alias,
669 container_id,
670 action
671 );
672 app.container_state
673 .queue_action(crate::app::ContainerActionRequest {
674 alias,
675 askpass,
676 runtime,
677 container_id,
678 container_name,
679 action,
680 });
681}
682
683pub(super) fn handle_key_push_key(
687 app: &mut App,
688 key: KeyEvent,
689 events_tx: &mpsc::Sender<AppEvent>,
690) {
691 match route_confirm_key(key) {
692 ConfirmAction::Yes => {
693 let key_index = match &app.screen {
694 Screen::ConfirmKeyPush { key_index } => *key_index,
695 _ => return,
696 };
697 let aliases = std::mem::take(&mut app.keys.push_mut().committed);
698 app.set_screen(Screen::HostList);
699 start_key_push(app, key_index, aliases, events_tx);
700 }
701 ConfirmAction::No => {
702 let key_index = match &app.screen {
705 Screen::ConfirmKeyPush { key_index } => *key_index,
706 _ => return,
707 };
708 app.keys.push_mut().committed.clear();
709 app.set_screen(Screen::KeyPushPicker { key_index });
710 }
711 ConfirmAction::Ignored => {}
712 }
713}
714
715fn start_key_push(
722 app: &mut App,
723 key_index: usize,
724 aliases: Vec<String>,
725 events_tx: &mpsc::Sender<AppEvent>,
726) {
727 if app.keys.push().expected_count > 0
733 || app
734 .keys
735 .push()
736 .worker
737 .as_ref()
738 .is_some_and(|h| !h.is_finished())
739 {
740 log::debug!(
741 "[purple] key_push: rejected second push, run already in progress ({} of {})",
742 app.keys.push().results.len(),
743 app.keys.push().expected_count
744 );
745 app.notify_warning(crate::messages::KEY_PUSH_ALREADY_IN_PROGRESS);
746 return;
747 }
748 if aliases.is_empty() {
749 log::debug!("[purple] key_push: rejected, no aliases committed");
750 app.notify_error(crate::messages::KEY_PUSH_NO_HOSTS_SELECTED);
751 return;
752 }
753 let Some(key_info) = app.keys.list().get(key_index).cloned() else {
754 return;
755 };
756 if key_info.is_certificate {
757 app.notify_error(crate::messages::KEY_PUSH_CERT_NOT_PUSHABLE);
758 return;
759 }
760 let pub_path = crate::key_push::pubkey_path_for(&key_info.display_path);
761 let raw = match crate::key_push::read_pubkey_file(&pub_path) {
762 Ok(s) => s,
763 Err(crate::key_push::PubkeyValidationError::TooLarge(n)) => {
764 log::warn!(
765 "[purple] key_push: pubkey too large path={} bytes={}",
766 pub_path.display(),
767 n
768 );
769 app.notify_error(crate::messages::key_push_pubkey_too_large(
770 &key_info.name,
771 n,
772 ));
773 return;
774 }
775 Err(crate::key_push::PubkeyValidationError::NotARegularFile) => {
776 log::warn!(
777 "[purple] key_push: pubkey not a regular file path={}",
778 pub_path.display()
779 );
780 app.notify_error(crate::messages::key_push_pubkey_not_regular(&key_info.name));
781 return;
782 }
783 Err(_) => {
784 app.notify_error(crate::messages::key_push_no_pubkey(&key_info.name));
788 return;
789 }
790 };
791 let pubkey = match crate::key_push::validate_pubkey(&raw) {
792 Ok(s) => s,
793 Err(err) => {
794 let detail = match &err {
795 crate::key_push::PubkeyValidationError::Empty => "file is empty",
796 crate::key_push::PubkeyValidationError::MultiLine => {
797 "must be a single line; multi-line input is rejected"
798 }
799 crate::key_push::PubkeyValidationError::UnsupportedType(_) => {
800 "key algorithm not allowed for static push"
801 }
802 crate::key_push::PubkeyValidationError::MalformedBase64 => {
803 "base64 key body did not parse"
804 }
805 _ => "unexpected format",
806 };
807 log::warn!(
808 "[purple] key_push: invalid pubkey path={} err={:?}",
809 pub_path.display(),
810 err
811 );
812 app.notify_error(crate::messages::key_push_invalid_pubkey(
813 &key_info.name,
814 detail,
815 ));
816 return;
817 }
818 };
819
820 let (run_id, cancel) = app.keys.push_mut().start_run(aliases.len());
822
823 app.notify_progress(crate::messages::key_push_in_progress(
824 &key_info.name,
825 aliases.len(),
826 ));
827
828 let config_path = app.hosts_state.ssh_config().path.clone();
829 let tx = events_tx.clone();
830 let pubkey_payload = pubkey;
831 let handle = std::thread::Builder::new()
832 .name("key-push".into())
833 .spawn(move || {
834 for alias in aliases {
835 if cancel.load(Ordering::Relaxed) {
836 break;
837 }
838 let outcome =
839 crate::key_push::push_to_host(&pubkey_payload, &alias, &config_path, &cancel);
840 let _ = tx.send(AppEvent::KeyPushResult {
841 run_id,
842 result: crate::key_push::KeyPushResult { alias, outcome },
843 });
844 }
845 });
846 match handle {
847 Ok(h) => {
848 app.keys.push_mut().worker = Some(h);
849 }
850 Err(e) => {
851 log::error!("[purple] key_push: failed to spawn worker: {}", e);
852 app.status_center.clear_sticky_status();
856 app.notify_error(crate::messages::key_push_thread_spawn_failed());
857 app.keys.push_mut().clear_inflight_state();
858 }
859 }
860}
861
862#[cfg(test)]
863mod key_push_confirm_tests {
864 use super::*;
871 use crate::ssh_config::model::SshConfigFile;
872 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
873
874 fn make_app() -> (App, std::path::PathBuf) {
875 let scratch = tempfile::tempdir().expect("tempdir").keep();
876 crate::preferences::set_path_override(scratch.join("preferences"));
877 crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
878 let config = SshConfigFile {
879 elements: SshConfigFile::parse_content("Host h1\n HostName 1.1.1.1\n"),
880 path: scratch.join("test_config"),
881 crlf: false,
882 bom: false,
883 };
884 let mut app = App::new(config);
885 let pub_path = scratch.join("id_test.pub");
888 std::fs::write(
889 &pub_path,
890 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 test@host\n",
891 )
892 .unwrap();
893 app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
894 name: "id_test".into(),
895 display_path: pub_path.with_extension("").to_string_lossy().into_owned(),
896 key_type: "ED25519".into(),
897 bits: "256".into(),
898 fingerprint: String::new(),
899 comment: "test@host".into(),
900 linked_hosts: vec![],
901 bishop_art: String::new(),
902 strength_score: 95,
903 encrypted: false,
904 agent_loaded: false,
905 is_certificate: false,
906 mtime_ts: None,
907 });
908 (app, scratch)
909 }
910
911 fn k(code: KeyCode) -> KeyEvent {
912 KeyEvent::new(code, KeyModifiers::NONE)
913 }
914
915 #[test]
916 fn n_returns_to_picker_with_key_index_preserved() {
917 let (mut app, _scratch) = make_app();
918 app.keys.push_mut().committed = vec!["h1".into()];
919 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
920 let (tx, _rx) = mpsc::channel();
921 handle_key_push_key(&mut app, k(KeyCode::Char('n')), &tx);
922 match app.screen {
923 Screen::KeyPushPicker { key_index } => assert_eq!(key_index, 0),
924 ref other => panic!("expected KeyPushPicker, got {:?}", other),
925 }
926 assert!(
927 app.keys.push().committed.is_empty(),
928 "n should drop the frozen selection"
929 );
930 }
931
932 #[test]
933 fn esc_routes_through_route_confirm_key_and_returns_to_picker() {
934 let (mut app, _scratch) = make_app();
935 app.keys.push_mut().committed = vec!["h1".into()];
936 app.screen = Screen::ConfirmKeyPush { key_index: 0 };
937 let (tx, _rx) = mpsc::channel();
938 handle_key_push_key(&mut app, k(KeyCode::Esc), &tx);
939 assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
940 }
941
942 #[test]
943 fn start_rejects_when_a_previous_run_is_still_in_flight() {
944 let (mut app, _scratch) = make_app();
945 app.keys.push_mut().expected_count = 2;
946 app.keys
947 .push_mut()
948 .results
949 .push(crate::key_push::KeyPushResult {
950 alias: "h1".into(),
951 outcome: crate::key_push::KeyPushOutcome::Appended,
952 });
953 let (tx, _rx) = mpsc::channel();
954 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
955 assert_eq!(
956 app.keys.push().expected_count,
957 2,
958 "guard must not reset in-flight state"
959 );
960 let toast = app.status_center.toast().expect("toast set");
961 assert!(
962 toast.text.contains("already running"),
963 "expected 'already running' warning, got: {}",
964 toast.text
965 );
966 }
967
968 #[test]
969 fn start_rejects_empty_aliases_and_does_not_spawn_worker() {
970 let (mut app, _scratch) = make_app();
971 let (tx, _rx) = mpsc::channel();
972 start_key_push(&mut app, 0, Vec::new(), &tx);
973 assert_eq!(app.keys.push().expected_count, 0);
974 assert!(app.keys.push().worker.is_none());
975 let toast = app.status_center.toast().expect("toast set");
976 assert!(toast.is_error());
977 }
978
979 #[test]
980 fn start_rejects_certificate_key() {
981 let (mut app, _scratch) = make_app();
982 app.keys.list_mut()[0].is_certificate = true;
983 let (tx, _rx) = mpsc::channel();
984 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
985 assert_eq!(app.keys.push().expected_count, 0);
986 assert!(app.keys.push().worker.is_none());
987 let toast = app.status_center.toast().expect("toast set");
988 assert!(toast.is_error());
989 assert!(toast.text.contains("Certificates"));
990 }
991
992 #[test]
993 fn start_rejects_missing_pubkey_file() {
994 let (mut app, _scratch) = make_app();
995 app.keys.list_mut()[0].display_path = "/tmp/purple-this-file-does-not-exist".into();
996 let (tx, _rx) = mpsc::channel();
997 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
998 assert_eq!(app.keys.push().expected_count, 0);
999 let toast = app.status_center.toast().expect("toast set");
1000 assert!(toast.is_error());
1001 }
1002
1003 #[test]
1004 fn start_rejects_invalid_pubkey_content() {
1005 let (mut app, scratch) = make_app();
1006 let pub_path = scratch.join("id_bad.pub");
1009 std::fs::write(
1010 &pub_path,
1011 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real\ncommand=\"evil\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 hack\n",
1012 )
1013 .unwrap();
1014 app.keys.list_mut()[0].display_path =
1015 pub_path.with_extension("").to_string_lossy().into_owned();
1016 app.keys.list_mut()[0].name = "id_bad".into();
1017 let (tx, _rx) = mpsc::channel();
1018 start_key_push(&mut app, 0, vec!["h1".into()], &tx);
1019 assert_eq!(app.keys.push().expected_count, 0);
1020 assert!(app.keys.push().worker.is_none());
1021 let toast = app.status_center.toast().expect("toast set");
1022 assert!(toast.is_error());
1023 assert!(toast.text.contains("validation"));
1024 }
1025}