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}
28
29impl PendingRetry {
30 pub fn alias(&self) -> &str {
31 match self {
32 PendingRetry::KeyPush { alias, .. }
33 | PendingRetry::FileBrowserListing { alias, .. } => alias,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum PasswordPromptField {
41 #[default]
42 Password,
43 Remember,
44}
45
46impl PasswordPromptField {
47 pub fn next(self) -> Self {
48 match self {
49 PasswordPromptField::Password => PasswordPromptField::Remember,
50 PasswordPromptField::Remember => PasswordPromptField::Password,
51 }
52 }
53}
54
55#[derive(Clone, PartialEq, Eq)]
58pub struct PasswordPromptState {
59 pub alias: String,
60 pub source_is_keychain: bool,
63 pub input: String,
64 pub remember: bool,
65 pub focus: PasswordPromptField,
66 pub retry: PendingRetry,
67}
68
69impl std::fmt::Debug for PasswordPromptState {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("PasswordPromptState")
74 .field("alias", &self.alias)
75 .field("source_is_keychain", &self.source_is_keychain)
76 .field("input_len", &self.input.chars().count())
77 .field("remember", &self.remember)
78 .field("focus", &self.focus)
79 .field("retry", &self.retry)
80 .finish()
81 }
82}
83
84impl PasswordPromptState {
85 pub fn new(alias: String, source_is_keychain: bool, retry: PendingRetry) -> Self {
86 Self {
87 alias,
88 source_is_keychain,
89 input: String::new(),
90 remember: true,
91 focus: PasswordPromptField::Password,
92 retry,
93 }
94 }
95}
96
97pub type SessionPasswords = HashMap<String, String>;
101
102pub fn source_allows_prompt(source: Option<&str>) -> bool {
106 matches!(source, None | Some("keychain"))
107}
108
109impl App {
110 pub(crate) fn askpass_source_for(&self, alias: &str) -> Option<String> {
114 self.hosts_state
115 .list
116 .iter()
117 .find(|h| h.alias == alias)
118 .and_then(|h| h.askpass.clone())
119 .or_else(|| crate::preferences::load_askpass_default(self.env.paths()))
120 }
121
122 pub(crate) fn refusal_is_this_host(&self, alias: &str, named: Option<&str>) -> bool {
131 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
132 return true;
135 };
136 if host.proxy_jump.trim().is_empty() && !host.has_proxy_command {
137 return true;
138 }
139 let Some(named) = named else {
142 return false;
143 };
144 let named = crate::connection::bare_host(named);
145 let target = if host.hostname.trim().is_empty() {
146 alias
147 } else {
148 host.hostname.trim()
149 };
150 named.eq_ignore_ascii_case(target)
151 }
152
153 pub(crate) fn dialog_open(&self) -> bool {
157 self.password_prompt.is_some()
158 || matches!(
159 self.screen,
160 super::Screen::PasswordPrompt | super::Screen::ConfirmHostKeyTrust { .. }
161 )
162 }
163
164 pub(crate) fn can_open_dialog(&self) -> bool {
176 !self.dialog_open()
177 && self.search.query().is_none()
178 && self.tags.input().is_none()
179 && self.jump.is_none()
180 && self.tunnels.pending_delete().is_none()
181 && self.snippets.pending_delete().is_none()
182 && !self.file_browser_is_busy()
183 && matches!(
184 self.screen,
185 super::Screen::HostList | super::Screen::FileBrowser { .. }
186 )
187 }
188
189 fn file_browser_is_busy(&self) -> bool {
193 self.file_browser_session.as_ref().is_some_and(|fb| {
194 fb.confirm_copy.is_some() || fb.transferring.is_some() || fb.transfer_error.is_some()
195 })
196 }
197
198 pub(crate) fn drop_stranded_password_prompt(&mut self) -> bool {
203 if self.password_prompt.is_none() || matches!(self.screen, super::Screen::PasswordPrompt) {
204 return false;
205 }
206 let alias = self
207 .password_prompt
208 .take()
209 .map(|s| s.alias)
210 .unwrap_or_default();
211 log::warn!("[purple] password prompt: dropped stranded state for alias={alias}");
212 self.notify_warning(crate::messages::askpass::prompt_cancelled(&alias));
213 true
214 }
215
216 pub(crate) fn password_was_supplied_for(&self, alias: &str) -> bool {
221 self.askpass_source_for(alias).is_some() || self.session_passwords.contains_key(alias)
222 }
223
224 pub(crate) fn open_password_prompt(
229 &mut self,
230 alias: &str,
231 retry: PendingRetry,
232 supplied_before: bool,
233 ) {
234 self.session_passwords.remove(alias);
235 let source_is_keychain = self.askpass_source_for(alias).as_deref() == Some("keychain");
236 log::debug!(
237 "[purple] password prompt: open alias={} keychain_source={} supplied_before={} retry={}",
238 alias,
239 source_is_keychain,
240 supplied_before,
241 match &retry {
242 PendingRetry::KeyPush { .. } => "key_push",
243 PendingRetry::FileBrowserListing { .. } => "file_browser",
244 }
245 );
246 self.password_prompt = Some(PasswordPromptState::new(
247 alias.to_string(),
248 source_is_keychain,
249 retry,
250 ));
251 self.set_screen(super::Screen::PasswordPrompt);
252 if supplied_before {
253 self.notify_warning(crate::messages::askpass::password_rejected(alias));
254 }
255 }
256
257 pub(crate) fn session_password_for(&self, alias: &str) -> Option<String> {
262 self.session_passwords.get(alias).cloned()
263 }
264
265 pub(crate) fn ssh_context_for(
271 &self,
272 alias: impl Into<String>,
273 askpass: Option<String>,
274 ) -> crate::ssh_context::OwnedSshContext {
275 let alias = alias.into();
276 crate::ssh_context::OwnedSshContext {
277 session_password: self.session_password_for(&alias),
278 has_tunnel: self.tunnels.active_contains(&alias),
279 alias,
280 config_path: self.reload.config_path().to_path_buf(),
281 askpass,
282 bw_session: self.bw_session.clone(),
283 trust_new_host_key: false,
284 env: std::sync::Arc::clone(&self.env),
285 }
286 }
287
288 pub(crate) fn open_host_key_trust(&mut self, alias: &str, retry: PendingRetry) {
291 let hostname = self
292 .hosts_state
293 .list
294 .iter()
295 .find(|h| h.alias == alias)
296 .map(|h| h.hostname.clone())
297 .filter(|h| !h.is_empty())
298 .unwrap_or_else(|| alias.to_string());
299 log::debug!(
300 "[purple] host key trust: open alias={} hostname={}",
301 alias,
302 hostname
303 );
304 self.set_screen(super::Screen::ConfirmHostKeyTrust {
305 alias: alias.to_string(),
306 hostname,
307 retry,
308 });
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::app::Screen;
316 use crate::ssh_config::model::SshConfigFile;
317
318 fn app_with(config: &str) -> App {
319 let scratch = tempfile::tempdir().expect("tempdir").keep();
320 let cfg = SshConfigFile {
321 elements: SshConfigFile::parse_content(config),
322 path: scratch.join("config"),
323 crlf: false,
324 bom: false,
325 };
326 App::new(cfg)
327 }
328
329 #[test]
330 fn source_allows_prompt_only_for_none_and_keychain() {
331 assert!(source_allows_prompt(None));
332 assert!(source_allows_prompt(Some("keychain")));
333 for other in [
334 "bw:item",
335 "op://v/i/f",
336 "proton:v/i/f",
337 "pass:x",
338 "vault:s#p",
339 "my-cmd %h",
340 ] {
341 assert!(!source_allows_prompt(Some(other)), "{other}");
342 }
343 }
344
345 #[test]
346 fn field_focus_cycles_between_the_two_fields() {
347 assert_eq!(
348 PasswordPromptField::Password.next(),
349 PasswordPromptField::Remember
350 );
351 assert_eq!(
352 PasswordPromptField::Remember.next(),
353 PasswordPromptField::Password
354 );
355 }
356
357 #[test]
358 fn new_state_defaults_to_remember_on_and_password_focus() {
359 let s = PasswordPromptState::new(
360 "h".into(),
361 false,
362 PendingRetry::KeyPush {
363 key_path: "~/.ssh/id_test".into(),
364 alias: "h".into(),
365 },
366 );
367 assert!(s.remember);
368 assert_eq!(s.focus, PasswordPromptField::Password);
369 assert!(s.input.is_empty());
370 }
371
372 #[test]
373 fn pending_retry_alias_names_the_host() {
374 let a = PendingRetry::KeyPush {
375 key_path: "~/.ssh/id_test".into(),
376 alias: "web".into(),
377 };
378 let b = PendingRetry::FileBrowserListing {
379 alias: "db".into(),
380 path: "/".into(),
381 };
382 assert_eq!(a.alias(), "web");
383 assert_eq!(b.alias(), "db");
384 }
385
386 #[test]
387 fn askpass_source_prefers_the_host_comment() {
388 let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass bw:item\n");
389 assert_eq!(app.askpass_source_for("h").as_deref(), Some("bw:item"));
390 assert_eq!(app.askpass_source_for("missing"), None);
391 }
392
393 #[test]
394 fn askpass_source_falls_back_to_the_global_default() {
395 let app = app_with("Host h\n HostName 1.1.1.1\n");
396 crate::preferences::save_askpass_default(app.env().paths(), "keychain").unwrap();
397 assert_eq!(app.askpass_source_for("h").as_deref(), Some("keychain"));
398 }
399
400 #[test]
401 fn open_password_prompt_sets_state_screen_and_drops_rejected_password() {
402 let mut app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
403 app.session_passwords
404 .insert("h".to_string(), "wrong".to_string());
405 let retry = PendingRetry::KeyPush {
406 key_path: "~/.ssh/id_test".into(),
407 alias: "h".into(),
408 };
409 app.open_password_prompt("h", retry.clone(), false);
410 assert_eq!(app.screen, Screen::PasswordPrompt);
411 let state = app.password_prompt.as_ref().expect("prompt state");
412 assert_eq!(state.alias, "h");
413 assert!(state.source_is_keychain);
414 assert_eq!(state.retry, retry);
415 assert!(
416 !app.session_passwords.contains_key("h"),
417 "a rejected session password must not survive into the retry"
418 );
419 }
420
421 #[test]
422 fn a_second_dialog_is_seen_as_already_open() {
423 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
424 assert!(!app.dialog_open());
425 app.open_password_prompt(
426 "h",
427 PendingRetry::KeyPush {
428 key_path: "~/.ssh/id_test".into(),
429 alias: "h".into(),
430 },
431 false,
432 );
433 assert!(app.dialog_open());
434 app.password_prompt = None;
435 app.screen = Screen::HostList;
436 app.open_host_key_trust(
437 "h",
438 PendingRetry::FileBrowserListing {
439 alias: "h".into(),
440 path: "/".into(),
441 },
442 );
443 assert!(app.dialog_open());
444 }
445
446 #[test]
447 fn a_reopened_prompt_says_the_last_password_was_refused() {
448 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
449 app.session_passwords
450 .insert("h".to_string(), "wrong".to_string());
451 assert!(app.password_was_supplied_for("h"));
452 let supplied = app.password_was_supplied_for("h");
453 app.open_password_prompt(
454 "h",
455 PendingRetry::KeyPush {
456 key_path: "~/.ssh/id_test".into(),
457 alias: "h".into(),
458 },
459 supplied,
460 );
461 let toast = app.status_center.toast().expect("toast");
462 assert!(toast.text.contains('h'), "got: {}", toast.text);
463 assert!(!app.session_passwords.contains_key("h"));
465 }
466
467 #[test]
468 fn a_keychain_source_counts_as_a_password_already_supplied() {
469 let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
470 assert!(app.password_was_supplied_for("h"));
471 let bare = app_with("Host h\n HostName 1.1.1.1\n");
472 assert!(!bare.password_was_supplied_for("h"));
473 }
474
475 #[test]
476 fn ssh_context_for_carries_the_session_password_and_defaults_to_strict_checking() {
477 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
478 app.session_passwords
479 .insert("h".to_string(), "hunter2".to_string());
480 let ctx = app.ssh_context_for("h", Some("keychain".to_string()));
481 assert_eq!(ctx.alias, "h");
482 assert_eq!(ctx.askpass.as_deref(), Some("keychain"));
483 assert_eq!(ctx.session_password.as_deref(), Some("hunter2"));
484 assert!(!ctx.trust_new_host_key);
485 assert!(!ctx.has_tunnel);
486 }
487
488 #[test]
489 fn ssh_context_for_leaves_the_password_empty_for_another_host() {
490 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
491 app.session_passwords
492 .insert("other".to_string(), "hunter2".to_string());
493 assert!(app.ssh_context_for("h", None).session_password.is_none());
494 }
495
496 #[test]
497 fn open_host_key_trust_names_the_hostname_and_falls_back_to_alias() {
498 let mut app = app_with("Host h\n HostName db.example.com\n");
499 let retry = PendingRetry::FileBrowserListing {
500 alias: "h".into(),
501 path: String::new(),
502 };
503 app.open_host_key_trust("h", retry.clone());
504 assert_eq!(
505 app.screen,
506 Screen::ConfirmHostKeyTrust {
507 alias: "h".into(),
508 hostname: "db.example.com".into(),
509 retry: retry.clone(),
510 }
511 );
512 app.open_host_key_trust("ghost", retry);
513 match &app.screen {
514 Screen::ConfirmHostKeyTrust { hostname, .. } => assert_eq!(hostname, "ghost"),
515 other => panic!("expected trust dialog, got {other:?}"),
516 }
517 }
518
519 #[test]
520 fn a_proxy_command_counts_as_a_bastion_in_front_of_the_host() {
521 let app =
525 app_with("Host h\n HostName db.example.com\n ProxyCommand ssh -W %h:%p bastion\n");
526 assert!(!app.refusal_is_this_host("h", None));
527 assert!(!app.refusal_is_this_host("h", Some("bastion.example.com")));
528 assert!(app.refusal_is_this_host("h", Some("db.example.com")));
529 }
530
531 #[test]
532 fn a_proxy_command_set_to_none_leaves_the_host_direct() {
533 let app = app_with("Host h\n HostName db.example.com\n ProxyCommand none\n");
536 assert!(app.refusal_is_this_host("h", None));
537 }
538
539 #[test]
540 fn a_dialog_waits_while_the_host_list_search_is_open() {
541 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
544 app.screen = Screen::HostList;
545 assert!(app.can_open_dialog());
546 app.search.set_query(Some("web".to_string()));
547 assert!(
548 !app.can_open_dialog(),
549 "a half typed search must keep the screen"
550 );
551 app.search.set_query(None);
552 assert!(app.can_open_dialog());
553 }
554
555 #[test]
556 fn a_dialog_waits_while_the_jump_bar_is_open() {
557 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
560 app.screen = Screen::HostList;
561 app.open_jump(crate::app::JumpMode::Hosts);
562 assert!(!app.can_open_dialog(), "the jump bar keeps the screen");
563 }
564
565 #[test]
566 fn a_dialog_waits_while_another_confirm_is_on_screen() {
567 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
571 app.screen = Screen::HostList;
572 app.tunnels.pending_delete = Some(0);
573 assert!(!app.can_open_dialog(), "a tunnel confirm keeps the screen");
574 app.tunnels.take_pending_delete();
575 assert!(app.can_open_dialog());
576
577 app.snippets.pending_delete = Some(0);
578 assert!(!app.can_open_dialog(), "a snippet confirm keeps the screen");
579 app.snippets.pending_delete = None;
580 assert!(app.can_open_dialog());
581 }
582
583 #[test]
584 fn a_dialog_waits_while_the_tag_input_is_open() {
585 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
586 app.screen = Screen::HostList;
587 app.tags.open_tag_input("web".to_string());
588 assert!(
589 !app.can_open_dialog(),
590 "a half typed tag must keep the screen"
591 );
592 }
593
594 #[test]
595 fn a_refusal_is_parsed_out_of_stderr_before_it_is_attributed() {
596 let app = app_with("Host h\n HostName target.example.com\n ProxyJump bastion\n");
599 let bastion = "ops@bastion.example.com: Permission denied (publickey,password).\n";
600 let named = crate::connection::denied_host(bastion);
601 assert_eq!(named, Some("bastion.example.com"));
602 assert!(
603 !app.refusal_is_this_host("h", named),
604 "the jump host's refusal is not the target's to answer"
605 );
606
607 let target = "ops@target.example.com: Permission denied (publickey,password).\n";
608 let named = crate::connection::denied_host(target);
609 assert!(app.refusal_is_this_host("h", named));
610 }
611
612 #[test]
613 fn a_bracketed_host_from_the_trust_line_matches_the_target() {
614 let app = app_with("Host h\n HostName 10.0.0.1\n ProxyJump bastion\n");
615 let stderr = "No ED25519 host key is known for [10.0.0.1]:2222 and you have requested strict checking.\n";
616 let named = crate::connection::unknown_host_key_host(stderr);
617 assert_eq!(named, Some("[10.0.0.1]:2222"));
618 assert!(app.refusal_is_this_host("h", named));
619 }
620}