1use std::collections::HashMap;
9
10use super::App;
11
12pub const PASSWORD_MAX_CHARS: usize = 512;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum PendingRetry {
20 KeyPush { key_path: String, alias: String },
24 FileBrowserListing { alias: String, path: String },
27 Tunnel { alias: String },
29}
30
31impl PendingRetry {
32 pub fn alias(&self) -> &str {
33 match self {
34 PendingRetry::KeyPush { alias, .. }
35 | PendingRetry::FileBrowserListing { alias, .. }
36 | PendingRetry::Tunnel { alias } => alias,
37 }
38 }
39
40 pub fn kind_label(&self) -> &'static str {
42 match self {
43 PendingRetry::KeyPush { .. } => "key_push",
44 PendingRetry::FileBrowserListing { .. } => "file_browser",
45 PendingRetry::Tunnel { .. } => "tunnel",
46 }
47 }
48
49 pub fn pin_flow(&self) -> crate::security_key::PinFlow {
51 match self {
52 PendingRetry::KeyPush { .. } => crate::security_key::PinFlow::KeyPush,
53 PendingRetry::FileBrowserListing { .. } => crate::security_key::PinFlow::FileBrowser,
54 PendingRetry::Tunnel { .. } => crate::security_key::PinFlow::Tunnel,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum PasswordPromptField {
62 #[default]
63 Password,
64 Remember,
65}
66
67impl PasswordPromptField {
68 pub fn next(self) -> Self {
69 match self {
70 PasswordPromptField::Password => PasswordPromptField::Remember,
71 PasswordPromptField::Remember => PasswordPromptField::Password,
72 }
73 }
74}
75
76#[derive(Clone, PartialEq, Eq)]
79pub struct PasswordPromptState {
80 pub alias: String,
81 pub source_is_keychain: bool,
84 pub input: String,
85 pub remember: bool,
86 pub focus: PasswordPromptField,
87 pub retry: PendingRetry,
88 pub security_key: Option<String>,
91 pub return_to_tunnel_list: Option<String>,
94}
95
96impl std::fmt::Debug for PasswordPromptState {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct("PasswordPromptState")
101 .field("alias", &self.alias)
102 .field("source_is_keychain", &self.source_is_keychain)
103 .field("input_len", &self.input.chars().count())
104 .field("remember", &self.remember)
105 .field("focus", &self.focus)
106 .field("retry", &self.retry)
107 .field("security_key", &self.security_key)
108 .field("return_to_tunnel_list", &self.return_to_tunnel_list)
109 .finish()
110 }
111}
112
113impl PasswordPromptState {
114 pub fn new(alias: String, source_is_keychain: bool, retry: PendingRetry) -> Self {
115 Self {
116 alias,
117 source_is_keychain,
118 input: String::new(),
119 remember: true,
120 focus: PasswordPromptField::Password,
121 retry,
122 security_key: None,
123 return_to_tunnel_list: None,
124 }
125 }
126
127 pub fn security_key_pin(alias: String, key_path: String, retry: PendingRetry) -> Self {
129 Self {
130 remember: false,
131 security_key: Some(key_path),
132 ..Self::new(alias, false, retry)
133 }
134 }
135}
136
137pub type SessionPasswords = HashMap<String, String>;
141
142pub fn source_allows_prompt(source: Option<&str>) -> bool {
146 matches!(source, None | Some("keychain"))
147}
148
149impl App {
150 pub(crate) fn askpass_source_for(&self, alias: &str) -> Option<String> {
154 self.hosts_state
155 .list
156 .iter()
157 .find(|h| h.alias == alias)
158 .and_then(|h| h.askpass.clone())
159 .or_else(|| crate::preferences::load_askpass_default(self.env.paths()))
160 }
161
162 pub(crate) fn refusal_is_this_host(&self, alias: &str, named: Option<&str>) -> bool {
171 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
172 return true;
175 };
176 if host.proxy_jump.trim().is_empty() && !host.has_proxy_command {
177 return true;
178 }
179 let Some(named) = named else {
182 return false;
183 };
184 let named = crate::connection::bare_host(named);
185 let target = if host.hostname.trim().is_empty() {
186 alias
187 } else {
188 host.hostname.trim()
189 };
190 named.eq_ignore_ascii_case(target)
191 }
192
193 pub(crate) fn dialog_open(&self) -> bool {
197 self.password_prompt.is_some()
198 || matches!(
199 self.screen,
200 super::Screen::PasswordPrompt | super::Screen::ConfirmHostKeyTrust { .. }
201 )
202 }
203
204 pub(crate) fn can_open_dialog(&self) -> bool {
216 !self.dialog_open()
217 && self.search.query().is_none()
218 && self.tags.input().is_none()
219 && self.jump.is_none()
220 && self.tunnels.pending_delete().is_none()
221 && self.snippets.pending_delete().is_none()
222 && !self.file_browser_is_busy()
223 && matches!(
224 self.screen,
225 super::Screen::HostList | super::Screen::FileBrowser { .. }
226 )
227 }
228
229 fn file_browser_is_busy(&self) -> bool {
233 self.file_browser_session.as_ref().is_some_and(|fb| {
234 fb.confirm_copy.is_some() || fb.transferring.is_some() || fb.transfer_error.is_some()
235 })
236 }
237
238 pub(crate) fn drop_stranded_password_prompt(&mut self) -> bool {
243 if self.password_prompt.is_none() || matches!(self.screen, super::Screen::PasswordPrompt) {
244 return false;
245 }
246 let alias = self
247 .password_prompt
248 .take()
249 .map(|s| s.alias)
250 .unwrap_or_default();
251 log::warn!("[purple] password prompt: dropped stranded state for alias={alias}");
252 self.notify_warning(crate::messages::askpass::prompt_cancelled(&alias));
253 true
254 }
255
256 pub(crate) fn password_was_supplied_for(&self, alias: &str) -> bool {
261 self.askpass_source_for(alias).is_some() || self.session_passwords.contains_key(alias)
262 }
263
264 pub(crate) fn open_password_prompt(
269 &mut self,
270 alias: &str,
271 retry: PendingRetry,
272 supplied_before: bool,
273 ) {
274 self.session_passwords.remove(alias);
275 let source_is_keychain = self.askpass_source_for(alias).as_deref() == Some("keychain");
276 log::debug!(
277 "[purple] password prompt: open alias={} keychain_source={} supplied_before={} retry={}",
278 alias,
279 source_is_keychain,
280 supplied_before,
281 retry.kind_label()
282 );
283 self.password_prompt = Some(PasswordPromptState::new(
284 alias.to_string(),
285 source_is_keychain,
286 retry,
287 ));
288 self.set_screen(super::Screen::PasswordPrompt);
289 if supplied_before {
290 self.notify_warning(crate::messages::askpass::password_rejected(alias));
291 }
292 }
293
294 pub(crate) fn session_password_for(&self, alias: &str) -> Option<String> {
299 self.session_passwords.get(alias).cloned()
300 }
301
302 pub(crate) fn ssh_context_for(
308 &self,
309 alias: impl Into<String>,
310 askpass: Option<String>,
311 ) -> crate::ssh_context::OwnedSshContext {
312 let alias = alias.into();
313 crate::ssh_context::OwnedSshContext {
314 session_password: self.session_password_for(&alias),
315 has_tunnel: self.tunnels.active_contains(&alias),
316 alias,
317 config_path: self.reload.config_path().to_path_buf(),
318 askpass,
319 bw_session: self.bw_session.clone(),
320 trust_new_host_key: false,
321 security_key: self.security_key_confirmed_run(),
322 env: std::sync::Arc::clone(&self.env),
323 }
324 }
325
326 pub(crate) fn file_browser_ssh_context(
329 &self,
330 alias: impl Into<String>,
331 askpass: Option<String>,
332 ) -> crate::ssh_context::OwnedSshContext {
333 let mut ctx = self.ssh_context_for(alias, askpass);
334 ctx.security_key =
335 self.security_key_run_for(&ctx.alias, crate::security_key::PinFlow::FileBrowser);
336 ctx
337 }
338
339 pub(crate) fn open_host_key_trust(&mut self, alias: &str, retry: PendingRetry) {
342 let hostname = self
343 .hosts_state
344 .list
345 .iter()
346 .find(|h| h.alias == alias)
347 .map(|h| h.hostname.clone())
348 .filter(|h| !h.is_empty())
349 .unwrap_or_else(|| alias.to_string());
350 log::debug!(
351 "[purple] host key trust: open alias={} hostname={}",
352 alias,
353 hostname
354 );
355 self.set_screen(super::Screen::ConfirmHostKeyTrust {
356 alias: alias.to_string(),
357 hostname,
358 retry,
359 });
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::app::Screen;
367 use crate::ssh_config::model::SshConfigFile;
368
369 fn app_with(config: &str) -> App {
370 let scratch = tempfile::tempdir().expect("tempdir").keep();
371 let cfg = SshConfigFile {
372 elements: SshConfigFile::parse_content(config),
373 path: scratch.join("config"),
374 crlf: false,
375 bom: false,
376 };
377 App::new(cfg)
378 }
379
380 #[test]
381 fn source_allows_prompt_only_for_none_and_keychain() {
382 assert!(source_allows_prompt(None));
383 assert!(source_allows_prompt(Some("keychain")));
384 for other in [
385 "bw:item",
386 "op://v/i/f",
387 "proton:v/i/f",
388 "pass:x",
389 "vault:s#p",
390 "my-cmd %h",
391 ] {
392 assert!(!source_allows_prompt(Some(other)), "{other}");
393 }
394 }
395
396 #[test]
397 fn field_focus_cycles_between_the_two_fields() {
398 assert_eq!(
399 PasswordPromptField::Password.next(),
400 PasswordPromptField::Remember
401 );
402 assert_eq!(
403 PasswordPromptField::Remember.next(),
404 PasswordPromptField::Password
405 );
406 }
407
408 #[test]
409 fn new_state_defaults_to_remember_on_and_password_focus() {
410 let s = PasswordPromptState::new(
411 "h".into(),
412 false,
413 PendingRetry::KeyPush {
414 key_path: "~/.ssh/id_test".into(),
415 alias: "h".into(),
416 },
417 );
418 assert!(s.remember);
419 assert_eq!(s.focus, PasswordPromptField::Password);
420 assert!(s.input.is_empty());
421 }
422
423 #[test]
424 fn pending_retry_alias_names_the_host() {
425 let a = PendingRetry::KeyPush {
426 key_path: "~/.ssh/id_test".into(),
427 alias: "web".into(),
428 };
429 let b = PendingRetry::FileBrowserListing {
430 alias: "db".into(),
431 path: "/".into(),
432 };
433 assert_eq!(a.alias(), "web");
434 assert_eq!(b.alias(), "db");
435 }
436
437 #[test]
438 fn askpass_source_prefers_the_host_comment() {
439 let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass bw:item\n");
440 assert_eq!(app.askpass_source_for("h").as_deref(), Some("bw:item"));
441 assert_eq!(app.askpass_source_for("missing"), None);
442 }
443
444 #[test]
445 fn askpass_source_falls_back_to_the_global_default() {
446 let app = app_with("Host h\n HostName 1.1.1.1\n");
447 crate::preferences::save_askpass_default(app.env().paths(), "keychain").unwrap();
448 assert_eq!(app.askpass_source_for("h").as_deref(), Some("keychain"));
449 }
450
451 #[test]
452 fn open_password_prompt_sets_state_screen_and_drops_rejected_password() {
453 let mut app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
454 app.session_passwords
455 .insert("h".to_string(), "wrong".to_string());
456 let retry = PendingRetry::KeyPush {
457 key_path: "~/.ssh/id_test".into(),
458 alias: "h".into(),
459 };
460 app.open_password_prompt("h", retry.clone(), false);
461 assert_eq!(app.screen, Screen::PasswordPrompt);
462 let state = app.password_prompt.as_ref().expect("prompt state");
463 assert_eq!(state.alias, "h");
464 assert!(state.source_is_keychain);
465 assert_eq!(state.retry, retry);
466 assert!(
467 !app.session_passwords.contains_key("h"),
468 "a rejected session password must not survive into the retry"
469 );
470 }
471
472 #[test]
473 fn a_second_dialog_is_seen_as_already_open() {
474 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
475 assert!(!app.dialog_open());
476 app.open_password_prompt(
477 "h",
478 PendingRetry::KeyPush {
479 key_path: "~/.ssh/id_test".into(),
480 alias: "h".into(),
481 },
482 false,
483 );
484 assert!(app.dialog_open());
485 app.password_prompt = None;
486 app.screen = Screen::HostList;
487 app.open_host_key_trust(
488 "h",
489 PendingRetry::FileBrowserListing {
490 alias: "h".into(),
491 path: "/".into(),
492 },
493 );
494 assert!(app.dialog_open());
495 }
496
497 #[test]
498 fn a_reopened_prompt_says_the_last_password_was_refused() {
499 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
500 app.session_passwords
501 .insert("h".to_string(), "wrong".to_string());
502 assert!(app.password_was_supplied_for("h"));
503 let supplied = app.password_was_supplied_for("h");
504 app.open_password_prompt(
505 "h",
506 PendingRetry::KeyPush {
507 key_path: "~/.ssh/id_test".into(),
508 alias: "h".into(),
509 },
510 supplied,
511 );
512 let toast = app.status_center.toast().expect("toast");
513 assert!(toast.text.contains('h'), "got: {}", toast.text);
514 assert!(!app.session_passwords.contains_key("h"));
516 }
517
518 #[test]
519 fn a_keychain_source_counts_as_a_password_already_supplied() {
520 let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
521 assert!(app.password_was_supplied_for("h"));
522 let bare = app_with("Host h\n HostName 1.1.1.1\n");
523 assert!(!bare.password_was_supplied_for("h"));
524 }
525
526 #[test]
527 fn ssh_context_for_carries_the_session_password_and_defaults_to_strict_checking() {
528 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
529 app.session_passwords
530 .insert("h".to_string(), "hunter2".to_string());
531 let ctx = app.ssh_context_for("h", Some("keychain".to_string()));
532 assert_eq!(ctx.alias, "h");
533 assert_eq!(ctx.askpass.as_deref(), Some("keychain"));
534 assert_eq!(ctx.session_password.as_deref(), Some("hunter2"));
535 assert!(!ctx.trust_new_host_key);
536 assert!(!ctx.has_tunnel);
537 }
538
539 #[test]
540 fn ssh_context_for_leaves_the_password_empty_for_another_host() {
541 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
542 app.session_passwords
543 .insert("other".to_string(), "hunter2".to_string());
544 assert!(app.ssh_context_for("h", None).session_password.is_none());
545 }
546
547 #[test]
548 fn open_host_key_trust_names_the_hostname_and_falls_back_to_alias() {
549 let mut app = app_with("Host h\n HostName db.example.com\n");
550 let retry = PendingRetry::FileBrowserListing {
551 alias: "h".into(),
552 path: String::new(),
553 };
554 app.open_host_key_trust("h", retry.clone());
555 assert_eq!(
556 app.screen,
557 Screen::ConfirmHostKeyTrust {
558 alias: "h".into(),
559 hostname: "db.example.com".into(),
560 retry: retry.clone(),
561 }
562 );
563 app.open_host_key_trust("ghost", retry);
564 match &app.screen {
565 Screen::ConfirmHostKeyTrust { hostname, .. } => assert_eq!(hostname, "ghost"),
566 other => panic!("expected trust dialog, got {other:?}"),
567 }
568 }
569
570 #[test]
571 fn a_proxy_command_counts_as_a_bastion_in_front_of_the_host() {
572 let app =
576 app_with("Host h\n HostName db.example.com\n ProxyCommand ssh -W %h:%p bastion\n");
577 assert!(!app.refusal_is_this_host("h", None));
578 assert!(!app.refusal_is_this_host("h", Some("bastion.example.com")));
579 assert!(app.refusal_is_this_host("h", Some("db.example.com")));
580 }
581
582 #[test]
583 fn a_proxy_command_set_to_none_leaves_the_host_direct() {
584 let app = app_with("Host h\n HostName db.example.com\n ProxyCommand none\n");
587 assert!(app.refusal_is_this_host("h", None));
588 }
589
590 #[test]
591 fn a_dialog_waits_while_the_host_list_search_is_open() {
592 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
595 app.screen = Screen::HostList;
596 assert!(app.can_open_dialog());
597 app.search.set_query(Some("web".to_string()));
598 assert!(
599 !app.can_open_dialog(),
600 "a half typed search must keep the screen"
601 );
602 app.search.set_query(None);
603 assert!(app.can_open_dialog());
604 }
605
606 #[test]
607 fn a_dialog_waits_while_the_jump_bar_is_open() {
608 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
611 app.screen = Screen::HostList;
612 app.open_jump(crate::app::JumpMode::Hosts);
613 assert!(!app.can_open_dialog(), "the jump bar keeps the screen");
614 }
615
616 #[test]
617 fn a_dialog_waits_while_another_confirm_is_on_screen() {
618 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
622 app.screen = Screen::HostList;
623 app.tunnels.pending_delete = Some(0);
624 assert!(!app.can_open_dialog(), "a tunnel confirm keeps the screen");
625 app.tunnels.take_pending_delete();
626 assert!(app.can_open_dialog());
627
628 app.snippets.pending_delete = Some(0);
629 assert!(!app.can_open_dialog(), "a snippet confirm keeps the screen");
630 app.snippets.pending_delete = None;
631 assert!(app.can_open_dialog());
632 }
633
634 #[test]
635 fn a_dialog_waits_while_the_tag_input_is_open() {
636 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
637 app.screen = Screen::HostList;
638 app.tags.open_tag_input("web".to_string());
639 assert!(
640 !app.can_open_dialog(),
641 "a half typed tag must keep the screen"
642 );
643 }
644
645 #[test]
646 fn a_refusal_is_parsed_out_of_stderr_before_it_is_attributed() {
647 let app = app_with("Host h\n HostName target.example.com\n ProxyJump bastion\n");
650 let bastion = "ops@bastion.example.com: Permission denied (publickey,password).\n";
651 let named = crate::connection::denied_host(bastion);
652 assert_eq!(named, Some("bastion.example.com"));
653 assert!(
654 !app.refusal_is_this_host("h", named),
655 "the jump host's refusal is not the target's to answer"
656 );
657
658 let target = "ops@target.example.com: Permission denied (publickey,password).\n";
659 let named = crate::connection::denied_host(target);
660 assert!(app.refusal_is_this_host("h", named));
661 }
662
663 #[test]
664 fn a_bracketed_host_from_the_trust_line_matches_the_target() {
665 let app = app_with("Host h\n HostName 10.0.0.1\n ProxyJump bastion\n");
666 let stderr = "No ED25519 host key is known for [10.0.0.1]:2222 and you have requested strict checking.\n";
667 let named = crate::connection::unknown_host_key_host(stderr);
668 assert_eq!(named, Some("[10.0.0.1]:2222"));
669 assert!(app.refusal_is_this_host("h", named));
670 }
671}