use std::collections::HashMap;
use super::App;
pub const PASSWORD_MAX_CHARS: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingRetry {
KeyPush { key_path: String, alias: String },
FileBrowserListing { alias: String, path: String },
}
impl PendingRetry {
pub fn alias(&self) -> &str {
match self {
PendingRetry::KeyPush { alias, .. }
| PendingRetry::FileBrowserListing { alias, .. } => alias,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PasswordPromptField {
#[default]
Password,
Remember,
}
impl PasswordPromptField {
pub fn next(self) -> Self {
match self {
PasswordPromptField::Password => PasswordPromptField::Remember,
PasswordPromptField::Remember => PasswordPromptField::Password,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PasswordPromptState {
pub alias: String,
pub source_is_keychain: bool,
pub input: String,
pub remember: bool,
pub focus: PasswordPromptField,
pub retry: PendingRetry,
}
impl std::fmt::Debug for PasswordPromptState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasswordPromptState")
.field("alias", &self.alias)
.field("source_is_keychain", &self.source_is_keychain)
.field("input_len", &self.input.chars().count())
.field("remember", &self.remember)
.field("focus", &self.focus)
.field("retry", &self.retry)
.finish()
}
}
impl PasswordPromptState {
pub fn new(alias: String, source_is_keychain: bool, retry: PendingRetry) -> Self {
Self {
alias,
source_is_keychain,
input: String::new(),
remember: true,
focus: PasswordPromptField::Password,
retry,
}
}
}
pub type SessionPasswords = HashMap<String, String>;
pub fn source_allows_prompt(source: Option<&str>) -> bool {
matches!(source, None | Some("keychain"))
}
impl App {
pub(crate) fn askpass_source_for(&self, alias: &str) -> Option<String> {
self.hosts_state
.list
.iter()
.find(|h| h.alias == alias)
.and_then(|h| h.askpass.clone())
.or_else(|| crate::preferences::load_askpass_default(self.env.paths()))
}
pub(crate) fn refusal_is_this_host(&self, alias: &str, named: Option<&str>) -> bool {
let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
return true;
};
if host.proxy_jump.trim().is_empty() {
return true;
}
let Some(named) = named else {
return false;
};
let named = crate::connection::bare_host(named);
let target = if host.hostname.trim().is_empty() {
alias
} else {
host.hostname.trim()
};
named.eq_ignore_ascii_case(target)
}
pub(crate) fn dialog_open(&self) -> bool {
self.password_prompt.is_some()
|| matches!(
self.screen,
super::Screen::PasswordPrompt | super::Screen::ConfirmHostKeyTrust { .. }
)
}
pub(crate) fn can_open_dialog(&self) -> bool {
!self.dialog_open()
&& matches!(
self.screen,
super::Screen::HostList | super::Screen::FileBrowser { .. }
)
}
pub(crate) fn password_was_supplied_for(&self, alias: &str) -> bool {
self.askpass_source_for(alias).is_some() || self.session_passwords.contains_key(alias)
}
pub(crate) fn open_password_prompt(
&mut self,
alias: &str,
retry: PendingRetry,
supplied_before: bool,
) {
self.session_passwords.remove(alias);
let source_is_keychain = self.askpass_source_for(alias).as_deref() == Some("keychain");
log::debug!(
"[purple] password prompt: open alias={} keychain_source={} supplied_before={} retry={}",
alias,
source_is_keychain,
supplied_before,
match &retry {
PendingRetry::KeyPush { .. } => "key_push",
PendingRetry::FileBrowserListing { .. } => "file_browser",
}
);
self.password_prompt = Some(PasswordPromptState::new(
alias.to_string(),
source_is_keychain,
retry,
));
self.set_screen(super::Screen::PasswordPrompt);
if supplied_before {
self.notify_warning(crate::messages::askpass::password_rejected(alias));
}
}
pub(crate) fn session_password_for(&self, alias: &str) -> Option<String> {
self.session_passwords.get(alias).cloned()
}
pub(crate) fn ssh_context_for(
&self,
alias: impl Into<String>,
askpass: Option<String>,
) -> crate::ssh_context::OwnedSshContext {
let alias = alias.into();
crate::ssh_context::OwnedSshContext {
session_password: self.session_password_for(&alias),
has_tunnel: self.tunnels.active_contains(&alias),
alias,
config_path: self.reload.config_path().to_path_buf(),
askpass,
bw_session: self.bw_session.clone(),
trust_new_host_key: false,
env: std::sync::Arc::clone(&self.env),
}
}
pub(crate) fn open_host_key_trust(&mut self, alias: &str, retry: PendingRetry) {
let hostname = self
.hosts_state
.list
.iter()
.find(|h| h.alias == alias)
.map(|h| h.hostname.clone())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| alias.to_string());
log::debug!(
"[purple] host key trust: open alias={} hostname={}",
alias,
hostname
);
self.set_screen(super::Screen::ConfirmHostKeyTrust {
alias: alias.to_string(),
hostname,
retry,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::Screen;
use crate::ssh_config::model::SshConfigFile;
fn app_with(config: &str) -> App {
let scratch = tempfile::tempdir().expect("tempdir").keep();
let cfg = SshConfigFile {
elements: SshConfigFile::parse_content(config),
path: scratch.join("config"),
crlf: false,
bom: false,
};
App::new(cfg)
}
#[test]
fn source_allows_prompt_only_for_none_and_keychain() {
assert!(source_allows_prompt(None));
assert!(source_allows_prompt(Some("keychain")));
for other in [
"bw:item",
"op://v/i/f",
"proton:v/i/f",
"pass:x",
"vault:s#p",
"my-cmd %h",
] {
assert!(!source_allows_prompt(Some(other)), "{other}");
}
}
#[test]
fn field_focus_cycles_between_the_two_fields() {
assert_eq!(
PasswordPromptField::Password.next(),
PasswordPromptField::Remember
);
assert_eq!(
PasswordPromptField::Remember.next(),
PasswordPromptField::Password
);
}
#[test]
fn new_state_defaults_to_remember_on_and_password_focus() {
let s = PasswordPromptState::new(
"h".into(),
false,
PendingRetry::KeyPush {
key_path: "~/.ssh/id_test".into(),
alias: "h".into(),
},
);
assert!(s.remember);
assert_eq!(s.focus, PasswordPromptField::Password);
assert!(s.input.is_empty());
}
#[test]
fn pending_retry_alias_names_the_host() {
let a = PendingRetry::KeyPush {
key_path: "~/.ssh/id_test".into(),
alias: "web".into(),
};
let b = PendingRetry::FileBrowserListing {
alias: "db".into(),
path: "/".into(),
};
assert_eq!(a.alias(), "web");
assert_eq!(b.alias(), "db");
}
#[test]
fn askpass_source_prefers_the_host_comment() {
let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass bw:item\n");
assert_eq!(app.askpass_source_for("h").as_deref(), Some("bw:item"));
assert_eq!(app.askpass_source_for("missing"), None);
}
#[test]
fn askpass_source_falls_back_to_the_global_default() {
let app = app_with("Host h\n HostName 1.1.1.1\n");
crate::preferences::save_askpass_default(app.env().paths(), "keychain").unwrap();
assert_eq!(app.askpass_source_for("h").as_deref(), Some("keychain"));
}
#[test]
fn open_password_prompt_sets_state_screen_and_drops_rejected_password() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
app.session_passwords
.insert("h".to_string(), "wrong".to_string());
let retry = PendingRetry::KeyPush {
key_path: "~/.ssh/id_test".into(),
alias: "h".into(),
};
app.open_password_prompt("h", retry.clone(), false);
assert_eq!(app.screen, Screen::PasswordPrompt);
let state = app.password_prompt.as_ref().expect("prompt state");
assert_eq!(state.alias, "h");
assert!(state.source_is_keychain);
assert_eq!(state.retry, retry);
assert!(
!app.session_passwords.contains_key("h"),
"a rejected session password must not survive into the retry"
);
}
#[test]
fn a_second_dialog_is_seen_as_already_open() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
assert!(!app.dialog_open());
app.open_password_prompt(
"h",
PendingRetry::KeyPush {
key_path: "~/.ssh/id_test".into(),
alias: "h".into(),
},
false,
);
assert!(app.dialog_open());
app.password_prompt = None;
app.screen = Screen::HostList;
app.open_host_key_trust(
"h",
PendingRetry::FileBrowserListing {
alias: "h".into(),
path: "/".into(),
},
);
assert!(app.dialog_open());
}
#[test]
fn a_reopened_prompt_says_the_last_password_was_refused() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
app.session_passwords
.insert("h".to_string(), "wrong".to_string());
assert!(app.password_was_supplied_for("h"));
let supplied = app.password_was_supplied_for("h");
app.open_password_prompt(
"h",
PendingRetry::KeyPush {
key_path: "~/.ssh/id_test".into(),
alias: "h".into(),
},
supplied,
);
let toast = app.status_center.toast().expect("toast");
assert!(toast.text.contains('h'), "got: {}", toast.text);
assert!(!app.session_passwords.contains_key("h"));
}
#[test]
fn a_keychain_source_counts_as_a_password_already_supplied() {
let app = app_with("Host h\n HostName 1.1.1.1\n # purple:askpass keychain\n");
assert!(app.password_was_supplied_for("h"));
let bare = app_with("Host h\n HostName 1.1.1.1\n");
assert!(!bare.password_was_supplied_for("h"));
}
#[test]
fn ssh_context_for_carries_the_session_password_and_defaults_to_strict_checking() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
app.session_passwords
.insert("h".to_string(), "hunter2".to_string());
let ctx = app.ssh_context_for("h", Some("keychain".to_string()));
assert_eq!(ctx.alias, "h");
assert_eq!(ctx.askpass.as_deref(), Some("keychain"));
assert_eq!(ctx.session_password.as_deref(), Some("hunter2"));
assert!(!ctx.trust_new_host_key);
assert!(!ctx.has_tunnel);
}
#[test]
fn ssh_context_for_leaves_the_password_empty_for_another_host() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
app.session_passwords
.insert("other".to_string(), "hunter2".to_string());
assert!(app.ssh_context_for("h", None).session_password.is_none());
}
#[test]
fn open_host_key_trust_names_the_hostname_and_falls_back_to_alias() {
let mut app = app_with("Host h\n HostName db.example.com\n");
let retry = PendingRetry::FileBrowserListing {
alias: "h".into(),
path: String::new(),
};
app.open_host_key_trust("h", retry.clone());
assert_eq!(
app.screen,
Screen::ConfirmHostKeyTrust {
alias: "h".into(),
hostname: "db.example.com".into(),
retry: retry.clone(),
}
);
app.open_host_key_trust("ghost", retry);
match &app.screen {
Screen::ConfirmHostKeyTrust { hostname, .. } => assert_eq!(hostname, "ghost"),
other => panic!("expected trust dialog, got {other:?}"),
}
}
}