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 {
129 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
130 return true;
133 };
134 if host.proxy_jump.trim().is_empty() {
135 return true;
136 }
137 let Some(named) = named else {
140 return false;
141 };
142 let named = crate::connection::bare_host(named);
143 let target = if host.hostname.trim().is_empty() {
144 alias
145 } else {
146 host.hostname.trim()
147 };
148 named.eq_ignore_ascii_case(target)
149 }
150
151 pub(crate) fn dialog_open(&self) -> bool {
155 self.password_prompt.is_some()
156 || matches!(
157 self.screen,
158 super::Screen::PasswordPrompt | super::Screen::ConfirmHostKeyTrust { .. }
159 )
160 }
161
162 pub(crate) fn can_open_dialog(&self) -> bool {
168 !self.dialog_open()
169 && matches!(
170 self.screen,
171 super::Screen::HostList | super::Screen::FileBrowser { .. }
172 )
173 }
174
175 pub(crate) fn password_was_supplied_for(&self, alias: &str) -> bool {
180 self.askpass_source_for(alias).is_some() || self.session_passwords.contains_key(alias)
181 }
182
183 pub(crate) fn open_password_prompt(
188 &mut self,
189 alias: &str,
190 retry: PendingRetry,
191 supplied_before: bool,
192 ) {
193 self.session_passwords.remove(alias);
194 let source_is_keychain = self.askpass_source_for(alias).as_deref() == Some("keychain");
195 log::debug!(
196 "[purple] password prompt: open alias={} keychain_source={} supplied_before={} retry={}",
197 alias,
198 source_is_keychain,
199 supplied_before,
200 match &retry {
201 PendingRetry::KeyPush { .. } => "key_push",
202 PendingRetry::FileBrowserListing { .. } => "file_browser",
203 }
204 );
205 self.password_prompt = Some(PasswordPromptState::new(
206 alias.to_string(),
207 source_is_keychain,
208 retry,
209 ));
210 self.set_screen(super::Screen::PasswordPrompt);
211 if supplied_before {
212 self.notify_warning(crate::messages::askpass::password_rejected(alias));
213 }
214 }
215
216 pub(crate) fn session_password_for(&self, alias: &str) -> Option<String> {
221 self.session_passwords.get(alias).cloned()
222 }
223
224 pub(crate) fn ssh_context_for(
230 &self,
231 alias: impl Into<String>,
232 askpass: Option<String>,
233 ) -> crate::ssh_context::OwnedSshContext {
234 let alias = alias.into();
235 crate::ssh_context::OwnedSshContext {
236 session_password: self.session_password_for(&alias),
237 has_tunnel: self.tunnels.active_contains(&alias),
238 alias,
239 config_path: self.reload.config_path().to_path_buf(),
240 askpass,
241 bw_session: self.bw_session.clone(),
242 trust_new_host_key: false,
243 env: std::sync::Arc::clone(&self.env),
244 }
245 }
246
247 pub(crate) fn open_host_key_trust(&mut self, alias: &str, retry: PendingRetry) {
250 let hostname = self
251 .hosts_state
252 .list
253 .iter()
254 .find(|h| h.alias == alias)
255 .map(|h| h.hostname.clone())
256 .filter(|h| !h.is_empty())
257 .unwrap_or_else(|| alias.to_string());
258 log::debug!(
259 "[purple] host key trust: open alias={} hostname={}",
260 alias,
261 hostname
262 );
263 self.set_screen(super::Screen::ConfirmHostKeyTrust {
264 alias: alias.to_string(),
265 hostname,
266 retry,
267 });
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::app::Screen;
275 use crate::ssh_config::model::SshConfigFile;
276
277 fn app_with(config: &str) -> App {
278 let scratch = tempfile::tempdir().expect("tempdir").keep();
279 let cfg = SshConfigFile {
280 elements: SshConfigFile::parse_content(config),
281 path: scratch.join("config"),
282 crlf: false,
283 bom: false,
284 };
285 App::new(cfg)
286 }
287
288 #[test]
289 fn source_allows_prompt_only_for_none_and_keychain() {
290 assert!(source_allows_prompt(None));
291 assert!(source_allows_prompt(Some("keychain")));
292 for other in [
293 "bw:item",
294 "op://v/i/f",
295 "proton:v/i/f",
296 "pass:x",
297 "vault:s#p",
298 "my-cmd %h",
299 ] {
300 assert!(!source_allows_prompt(Some(other)), "{other}");
301 }
302 }
303
304 #[test]
305 fn field_focus_cycles_between_the_two_fields() {
306 assert_eq!(
307 PasswordPromptField::Password.next(),
308 PasswordPromptField::Remember
309 );
310 assert_eq!(
311 PasswordPromptField::Remember.next(),
312 PasswordPromptField::Password
313 );
314 }
315
316 #[test]
317 fn new_state_defaults_to_remember_on_and_password_focus() {
318 let s = PasswordPromptState::new(
319 "h".into(),
320 false,
321 PendingRetry::KeyPush {
322 key_path: "~/.ssh/id_test".into(),
323 alias: "h".into(),
324 },
325 );
326 assert!(s.remember);
327 assert_eq!(s.focus, PasswordPromptField::Password);
328 assert!(s.input.is_empty());
329 }
330
331 #[test]
332 fn pending_retry_alias_names_the_host() {
333 let a = PendingRetry::KeyPush {
334 key_path: "~/.ssh/id_test".into(),
335 alias: "web".into(),
336 };
337 let b = PendingRetry::FileBrowserListing {
338 alias: "db".into(),
339 path: "/".into(),
340 };
341 assert_eq!(a.alias(), "web");
342 assert_eq!(b.alias(), "db");
343 }
344
345 #[test]
346 fn askpass_source_prefers_the_host_comment() {
347 let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass bw:item\n");
348 assert_eq!(app.askpass_source_for("h").as_deref(), Some("bw:item"));
349 assert_eq!(app.askpass_source_for("missing"), None);
350 }
351
352 #[test]
353 fn askpass_source_falls_back_to_the_global_default() {
354 let app = app_with("Host h\n HostName 1.1.1.1\n");
355 crate::preferences::save_askpass_default(app.env().paths(), "keychain").unwrap();
356 assert_eq!(app.askpass_source_for("h").as_deref(), Some("keychain"));
357 }
358
359 #[test]
360 fn open_password_prompt_sets_state_screen_and_drops_rejected_password() {
361 let mut app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
362 app.session_passwords
363 .insert("h".to_string(), "wrong".to_string());
364 let retry = PendingRetry::KeyPush {
365 key_path: "~/.ssh/id_test".into(),
366 alias: "h".into(),
367 };
368 app.open_password_prompt("h", retry.clone(), false);
369 assert_eq!(app.screen, Screen::PasswordPrompt);
370 let state = app.password_prompt.as_ref().expect("prompt state");
371 assert_eq!(state.alias, "h");
372 assert!(state.source_is_keychain);
373 assert_eq!(state.retry, retry);
374 assert!(
375 !app.session_passwords.contains_key("h"),
376 "a rejected session password must not survive into the retry"
377 );
378 }
379
380 #[test]
381 fn a_second_dialog_is_seen_as_already_open() {
382 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
383 assert!(!app.dialog_open());
384 app.open_password_prompt(
385 "h",
386 PendingRetry::KeyPush {
387 key_path: "~/.ssh/id_test".into(),
388 alias: "h".into(),
389 },
390 false,
391 );
392 assert!(app.dialog_open());
393 app.password_prompt = None;
394 app.screen = Screen::HostList;
395 app.open_host_key_trust(
396 "h",
397 PendingRetry::FileBrowserListing {
398 alias: "h".into(),
399 path: "/".into(),
400 },
401 );
402 assert!(app.dialog_open());
403 }
404
405 #[test]
406 fn a_reopened_prompt_says_the_last_password_was_refused() {
407 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
408 app.session_passwords
409 .insert("h".to_string(), "wrong".to_string());
410 assert!(app.password_was_supplied_for("h"));
411 let supplied = app.password_was_supplied_for("h");
412 app.open_password_prompt(
413 "h",
414 PendingRetry::KeyPush {
415 key_path: "~/.ssh/id_test".into(),
416 alias: "h".into(),
417 },
418 supplied,
419 );
420 let toast = app.status_center.toast().expect("toast");
421 assert!(toast.text.contains('h'), "got: {}", toast.text);
422 assert!(!app.session_passwords.contains_key("h"));
424 }
425
426 #[test]
427 fn a_keychain_source_counts_as_a_password_already_supplied() {
428 let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
429 assert!(app.password_was_supplied_for("h"));
430 let bare = app_with("Host h\n HostName 1.1.1.1\n");
431 assert!(!bare.password_was_supplied_for("h"));
432 }
433
434 #[test]
435 fn ssh_context_for_carries_the_session_password_and_defaults_to_strict_checking() {
436 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
437 app.session_passwords
438 .insert("h".to_string(), "hunter2".to_string());
439 let ctx = app.ssh_context_for("h", Some("keychain".to_string()));
440 assert_eq!(ctx.alias, "h");
441 assert_eq!(ctx.askpass.as_deref(), Some("keychain"));
442 assert_eq!(ctx.session_password.as_deref(), Some("hunter2"));
443 assert!(!ctx.trust_new_host_key);
444 assert!(!ctx.has_tunnel);
445 }
446
447 #[test]
448 fn ssh_context_for_leaves_the_password_empty_for_another_host() {
449 let mut app = app_with("Host h\n HostName 1.1.1.1\n");
450 app.session_passwords
451 .insert("other".to_string(), "hunter2".to_string());
452 assert!(app.ssh_context_for("h", None).session_password.is_none());
453 }
454
455 #[test]
456 fn open_host_key_trust_names_the_hostname_and_falls_back_to_alias() {
457 let mut app = app_with("Host h\n HostName db.example.com\n");
458 let retry = PendingRetry::FileBrowserListing {
459 alias: "h".into(),
460 path: String::new(),
461 };
462 app.open_host_key_trust("h", retry.clone());
463 assert_eq!(
464 app.screen,
465 Screen::ConfirmHostKeyTrust {
466 alias: "h".into(),
467 hostname: "db.example.com".into(),
468 retry: retry.clone(),
469 }
470 );
471 app.open_host_key_trust("ghost", retry);
472 match &app.screen {
473 Screen::ConfirmHostKeyTrust { hostname, .. } => assert_eq!(hostname, "ghost"),
474 other => panic!("expected trust dialog, got {other:?}"),
475 }
476 }
477}