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 },
Tunnel { alias: String },
}
impl PendingRetry {
pub fn alias(&self) -> &str {
match self {
PendingRetry::KeyPush { alias, .. }
| PendingRetry::FileBrowserListing { alias, .. }
| PendingRetry::Tunnel { alias } => alias,
}
}
pub fn kind_label(&self) -> &'static str {
match self {
PendingRetry::KeyPush { .. } => "key_push",
PendingRetry::FileBrowserListing { .. } => "file_browser",
PendingRetry::Tunnel { .. } => "tunnel",
}
}
pub fn pin_flow(&self) -> crate::security_key::PinFlow {
match self {
PendingRetry::KeyPush { .. } => crate::security_key::PinFlow::KeyPush,
PendingRetry::FileBrowserListing { .. } => crate::security_key::PinFlow::FileBrowser,
PendingRetry::Tunnel { .. } => crate::security_key::PinFlow::Tunnel,
}
}
}
#[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,
pub security_key: Option<String>,
pub return_to_tunnel_list: Option<String>,
}
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)
.field("security_key", &self.security_key)
.field("return_to_tunnel_list", &self.return_to_tunnel_list)
.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,
security_key: None,
return_to_tunnel_list: None,
}
}
pub fn security_key_pin(alias: String, key_path: String, retry: PendingRetry) -> Self {
Self {
remember: false,
security_key: Some(key_path),
..Self::new(alias, false, 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() && !host.has_proxy_command {
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()
&& self.search.query().is_none()
&& self.tags.input().is_none()
&& self.jump.is_none()
&& self.tunnels.pending_delete().is_none()
&& self.snippets.pending_delete().is_none()
&& !self.file_browser_is_busy()
&& matches!(
self.screen,
super::Screen::HostList | super::Screen::FileBrowser { .. }
)
}
fn file_browser_is_busy(&self) -> bool {
self.file_browser_session.as_ref().is_some_and(|fb| {
fb.confirm_copy.is_some() || fb.transferring.is_some() || fb.transfer_error.is_some()
})
}
pub(crate) fn drop_stranded_password_prompt(&mut self) -> bool {
if self.password_prompt.is_none() || matches!(self.screen, super::Screen::PasswordPrompt) {
return false;
}
let alias = self
.password_prompt
.take()
.map(|s| s.alias)
.unwrap_or_default();
log::warn!("[purple] password prompt: dropped stranded state for alias={alias}");
self.notify_warning(crate::messages::askpass::prompt_cancelled(&alias));
true
}
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,
retry.kind_label()
);
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,
security_key: self.security_key_confirmed_run(),
env: std::sync::Arc::clone(&self.env),
}
}
pub(crate) fn file_browser_ssh_context(
&self,
alias: impl Into<String>,
askpass: Option<String>,
) -> crate::ssh_context::OwnedSshContext {
let mut ctx = self.ssh_context_for(alias, askpass);
ctx.security_key =
self.security_key_run_for(&ctx.alias, crate::security_key::PinFlow::FileBrowser);
ctx
}
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:?}"),
}
}
#[test]
fn a_proxy_command_counts_as_a_bastion_in_front_of_the_host() {
let app =
app_with("Host h\n HostName db.example.com\n ProxyCommand ssh -W %h:%p bastion\n");
assert!(!app.refusal_is_this_host("h", None));
assert!(!app.refusal_is_this_host("h", Some("bastion.example.com")));
assert!(app.refusal_is_this_host("h", Some("db.example.com")));
}
#[test]
fn a_proxy_command_set_to_none_leaves_the_host_direct() {
let app = app_with("Host h\n HostName db.example.com\n ProxyCommand none\n");
assert!(app.refusal_is_this_host("h", None));
}
#[test]
fn a_dialog_waits_while_the_host_list_search_is_open() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
app.screen = Screen::HostList;
assert!(app.can_open_dialog());
app.search.set_query(Some("web".to_string()));
assert!(
!app.can_open_dialog(),
"a half typed search must keep the screen"
);
app.search.set_query(None);
assert!(app.can_open_dialog());
}
#[test]
fn a_dialog_waits_while_the_jump_bar_is_open() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
app.screen = Screen::HostList;
app.open_jump(crate::app::JumpMode::Hosts);
assert!(!app.can_open_dialog(), "the jump bar keeps the screen");
}
#[test]
fn a_dialog_waits_while_another_confirm_is_on_screen() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
app.screen = Screen::HostList;
app.tunnels.pending_delete = Some(0);
assert!(!app.can_open_dialog(), "a tunnel confirm keeps the screen");
app.tunnels.take_pending_delete();
assert!(app.can_open_dialog());
app.snippets.pending_delete = Some(0);
assert!(!app.can_open_dialog(), "a snippet confirm keeps the screen");
app.snippets.pending_delete = None;
assert!(app.can_open_dialog());
}
#[test]
fn a_dialog_waits_while_the_tag_input_is_open() {
let mut app = app_with("Host h\n HostName 1.1.1.1\n");
app.screen = Screen::HostList;
app.tags.open_tag_input("web".to_string());
assert!(
!app.can_open_dialog(),
"a half typed tag must keep the screen"
);
}
#[test]
fn a_refusal_is_parsed_out_of_stderr_before_it_is_attributed() {
let app = app_with("Host h\n HostName target.example.com\n ProxyJump bastion\n");
let bastion = "ops@bastion.example.com: Permission denied (publickey,password).\n";
let named = crate::connection::denied_host(bastion);
assert_eq!(named, Some("bastion.example.com"));
assert!(
!app.refusal_is_this_host("h", named),
"the jump host's refusal is not the target's to answer"
);
let target = "ops@target.example.com: Permission denied (publickey,password).\n";
let named = crate::connection::denied_host(target);
assert!(app.refusal_is_this_host("h", named));
}
#[test]
fn a_bracketed_host_from_the_trust_line_matches_the_target() {
let app = app_with("Host h\n HostName 10.0.0.1\n ProxyJump bastion\n");
let stderr = "No ED25519 host key is known for [10.0.0.1]:2222 and you have requested strict checking.\n";
let named = crate::connection::unknown_host_key_host(stderr);
assert_eq!(named, Some("[10.0.0.1]:2222"));
assert!(app.refusal_is_this_host("h", named));
}
}