use crate::app::{App, KeyPushPrompt};
pub(crate) fn handle_key_push_result(
app: &mut App,
run_id: u64,
result: crate::key_push::KeyPushResult,
) {
if run_id != app.keys.push().run_id {
log::debug!(
"[purple] key_push: dropping stale result for alias={} (event run_id={} current={})",
result.alias,
run_id,
app.keys.push().run_id
);
return;
}
let expected = app.keys.push().expected_count;
if expected == 0 {
return;
}
app.keys.push_mut().results.push(result);
if app.keys.push().results.len() < expected {
return;
}
finalize_key_push(app);
}
fn finalize_key_push(app: &mut App) {
use crate::key_push::KeyPushOutcome;
let mut appended = 0usize;
let mut already = 0usize;
let mut failed: Vec<(String, String)> = Vec::new();
let mut prompts: Vec<KeyPushPrompt> = Vec::new();
let results = app.keys.push().results.clone();
let run_key_path = app.keys.push().key_path.clone();
for r in &results {
match &r.outcome {
KeyPushOutcome::Appended => appended += 1,
KeyPushOutcome::AlreadyPresent => already += 1,
KeyPushOutcome::Failed(msg) => failed.push((r.alias.clone(), msg.clone())),
KeyPushOutcome::UnknownHostKey { detail, host } => {
if app.refusal_is_this_host(&r.alias, host.as_deref()) {
prompts.push(KeyPushPrompt::Trust {
alias: r.alias.clone(),
key_path: run_key_path.clone(),
});
} else {
log::debug!(
"[external] key_push: unknown host key came from a hop alias={} hop={:?} err={}",
r.alias,
host,
detail
);
failed.push((
r.alias.clone(),
crate::messages::host_key_trust::hop_is_unknown(
host.as_deref().unwrap_or(&r.alias),
),
));
}
}
KeyPushOutcome::NeedsPassword { detail, host } => {
let source = app.askpass_source_for(&r.alias);
if !app.refusal_is_this_host(&r.alias, host.as_deref()) {
log::debug!(
"[external] key_push: a hop refused alias={} hop={:?} err={}",
r.alias,
host,
detail
);
failed.push((
r.alias.clone(),
crate::messages::askpass::hop_refused(host.as_deref().unwrap_or(&r.alias)),
));
} else if crate::askpass::take_withheld(app.env.paths(), &r.alias) {
log::debug!(
"[external] key_push: prompt named no host alias={} err={}",
r.alias,
detail
);
failed.push((
r.alias.clone(),
crate::messages::askpass::prompt_names_no_host(&r.alias),
));
} else if crate::app::source_allows_prompt(source.as_deref()) {
prompts.push(KeyPushPrompt::Password {
alias: r.alias.clone(),
key_path: run_key_path.clone(),
});
} else {
let label = crate::askpass::describe_source(source.as_deref().unwrap_or(""));
log::debug!(
"[external] key_push: source did not deliver alias={} source={} err={}",
r.alias,
label,
detail
);
failed.push((
r.alias.clone(),
crate::messages::askpass::source_did_not_deliver(&r.alias, label),
));
}
}
}
}
let done = appended + already;
app.status_center.clear_sticky_status();
if !failed.is_empty() {
if failed.len() == 1 && done == 0 && prompts.is_empty() {
let (alias, why) = &failed[0];
app.notify_sticky_error(crate::messages::key_push_single_failure(alias, why));
} else if done == 0 && prompts.is_empty() {
app.notify_sticky_error(crate::messages::key_push_all_failed(failed.len()));
} else {
let mut body = crate::messages::key_push_partial_failure(done, failed.len());
let preview: Vec<&str> = failed.iter().take(5).map(|(a, _)| a.as_str()).collect();
if !preview.is_empty() {
body.push_str(" Failed: ");
body.push_str(&preview.join(", "));
if failed.len() > preview.len() {
use std::fmt::Write;
let _ = write!(body, ", +{} more", failed.len() - preview.len());
}
body.push('.');
}
app.notify_sticky_error(body);
}
} else if !prompts.is_empty() {
app.notify(crate::messages::key_push_pending_answers(
done,
prompts.len(),
));
} else {
app.notify(crate::messages::key_push_success(appended, already));
}
for (alias, msg) in &failed {
log::warn!("[external] key_push: failed alias={} err={}", alias, msg);
}
for p in &prompts {
log::debug!(
"[purple] key_push: queued {} dialog for alias={}",
match p {
KeyPushPrompt::Trust { .. } => "trust",
KeyPushPrompt::Password { .. } => "password",
},
p.alias()
);
}
if appended > 0 {
let ssh_dir = crate::ssh_keys::resolve_ssh_dir(app.env().paths());
if let Some(dir) = ssh_dir {
let keys =
crate::ssh_keys::discover_keys(app.env().paths(), &dir, app.hosts_state.list());
app.keys.set_list(keys);
if let Some(sel) = app.keys.list_state().selected() {
if app.keys.list().is_empty() {
app.keys.list_state_mut().select(None);
} else if sel >= app.keys.list().len() {
let last = app.keys.list().len() - 1;
app.keys.list_state_mut().select(Some(last));
}
}
}
}
crate::askpass::cleanup_marker(app.env.paths(), "");
app.keys.push_mut().finish_run();
app.keys.push_mut().pending_prompts.extend(prompts);
drain_next_key_push_prompt(app);
}
pub(crate) fn drain_next_key_push_prompt(app: &mut App) -> bool {
if app.keys.push().expected_count > 0 {
return false;
}
app.drop_stranded_password_prompt();
if !app.can_open_dialog() {
return false;
}
while let Some(prompt) = app.keys.push_mut().pending_prompts.pop_front() {
let key_path = prompt.key_path().to_string();
if !app.keys.list().iter().any(|k| k.display_path == key_path) {
log::debug!(
"[purple] key_push: dropping queued dialog for alias={}, key {} is gone",
prompt.alias(),
key_path
);
continue;
}
let alias = prompt.alias().to_string();
let retry = crate::app::PendingRetry::KeyPush {
key_path,
alias: alias.clone(),
};
match prompt {
KeyPushPrompt::Trust { .. } => app.open_host_key_trust(&alias, retry),
KeyPushPrompt::Password { .. } => {
let supplied = app.password_was_supplied_for(&alias);
app.open_password_prompt(&alias, retry, supplied);
}
}
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::key_push::{KeyPushOutcome, KeyPushResult};
use crate::ssh_config::model::SshConfigFile;
fn make_app() -> App {
make_app_with("")
}
fn make_app_with(config: &str) -> App {
let scratch = tempfile::tempdir().expect("tempdir").keep();
let config = SshConfigFile {
elements: SshConfigFile::parse_content(config),
path: scratch.join("test_config"),
crlf: false,
bom: false,
};
let mut app = App::new(config);
if let Some(ssh_dir) = crate::ssh_keys::resolve_ssh_dir(app.env().paths()) {
std::fs::create_dir_all(&ssh_dir).unwrap();
}
app.keys.push_mut().run_id = 1;
app
}
fn app_with_hosts(config: &str) -> App {
let mut app = make_app_with(config);
app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
name: "id_test".into(),
display_path: "~/.ssh/id_test".into(),
key_type: "ED25519".into(),
bits: "256".into(),
fingerprint: String::new(),
comment: String::new(),
linked_hosts: vec![],
bishop_art: String::new(),
strength_score: 95,
encrypted: false,
agent_loaded: false,
is_certificate: false,
mtime_ts: None,
});
app
}
fn result(alias: &str, outcome: KeyPushOutcome) -> KeyPushResult {
KeyPushResult {
alias: alias.to_string(),
outcome,
}
}
fn needs_password_from(host: Option<&str>) -> KeyPushOutcome {
KeyPushOutcome::NeedsPassword {
detail: "Permission denied (password).".into(),
host: host.map(str::to_string),
}
}
fn unknown_host_key_from(host: Option<&str>) -> KeyPushOutcome {
KeyPushOutcome::UnknownHostKey {
detail: "No ED25519 host key is known".into(),
host: host.map(str::to_string),
}
}
#[test]
fn handle_result_does_not_finalize_below_expected() {
let mut app = make_app();
app.keys.push_mut().expected_count = 3;
handle_key_push_result(&mut app, 1, result("h1", KeyPushOutcome::AlreadyPresent));
assert_eq!(app.keys.push().results.len(), 1);
assert_eq!(
app.keys.push().expected_count,
3,
"should not finalize early"
);
}
#[test]
fn handle_result_skips_when_expected_zero() {
let mut app = make_app();
app.keys.push_mut().expected_count = 0;
handle_key_push_result(&mut app, 1, result("h1", KeyPushOutcome::Appended));
assert!(app.keys.push().results.is_empty());
}
#[test]
fn handle_result_drops_stale_run_id() {
let mut app = make_app();
app.keys.push_mut().expected_count = 2;
app.keys.push_mut().run_id = 7;
handle_key_push_result(&mut app, 6, result("h-stale", KeyPushOutcome::Appended));
assert!(
app.keys.push().results.is_empty(),
"stale-run event must not push into the new run's results"
);
}
#[test]
fn finalize_all_already_present_emits_success_toast() {
let mut app = make_app();
app.keys.push_mut().expected_count = 2;
app.keys
.push_mut()
.results
.push(result("h1", KeyPushOutcome::AlreadyPresent));
handle_key_push_result(&mut app, 1, result("h2", KeyPushOutcome::AlreadyPresent));
assert_eq!(app.keys.push().expected_count, 0);
assert!(app.keys.push().results.is_empty());
assert!(app.keys.push().selected.is_empty());
let toast = app.status_center.toast().expect("toast set");
assert!(!toast.sticky, "fully-successful run is a plain toast");
}
#[test]
fn finalize_all_failed_emits_sticky_error() {
let mut app = make_app();
app.keys.push_mut().expected_count = 2;
app.keys
.push_mut()
.results
.push(result("h1", KeyPushOutcome::Failed("oops".into())));
handle_key_push_result(
&mut app,
1,
result("h2", KeyPushOutcome::Failed("also bad".into())),
);
assert_eq!(app.keys.push().expected_count, 0);
let status = app.status_center.status().expect("sticky status");
assert!(
status.sticky && status.is_error(),
"all-failed should be sticky-error"
);
}
#[test]
fn finalize_partial_failure_is_sticky_and_names_failed_hosts() {
let mut app = make_app();
app.keys.push_mut().expected_count = 3;
app.keys
.push_mut()
.results
.push(result("h1", KeyPushOutcome::AlreadyPresent));
app.keys
.push_mut()
.results
.push(result("h2", KeyPushOutcome::Failed("bad".into())));
handle_key_push_result(&mut app, 1, result("h3", KeyPushOutcome::AlreadyPresent));
assert_eq!(app.keys.push().expected_count, 0);
let status = app.status_center.status().expect("sticky status set");
assert!(
status.sticky && status.is_error(),
"partial failure is sticky so the user sees which hosts failed"
);
assert!(
status.text.contains("h2"),
"failed alias must appear in body: {}",
status.text
);
}
fn run_with(app: &mut App, results: Vec<KeyPushResult>) {
app.keys.push_mut().expected_count = results.len();
app.keys.push_mut().key_path = "~/.ssh/id_test".to_string();
let last = results.len().saturating_sub(1);
for (i, r) in results.into_iter().enumerate() {
if i < last {
app.keys.push_mut().results.push(r);
} else {
handle_key_push_result(app, 1, r);
}
}
}
#[test]
fn a_password_failure_opens_the_prompt_instead_of_reporting_a_failure() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n");
run_with(&mut app, vec![result("h1", needs_password_from(None))]);
assert_eq!(app.screen, crate::app::Screen::PasswordPrompt);
assert_eq!(
app.password_prompt.as_ref().map(|s| s.alias.as_str()),
Some("h1")
);
let said = app
.status_center
.toast()
.map(|t| t.text.clone())
.or_else(|| app.status_center.status().map(|s| s.text.clone()))
.expect("the run reports what it waits on");
assert!(said.contains("1 host(s) need an answer"), "got: {said}");
}
#[test]
fn an_unknown_host_key_opens_the_trust_dialog() {
let mut app = app_with_hosts("Host h1\n HostName db.example.com\n");
run_with(&mut app, vec![result("h1", unknown_host_key_from(None))]);
match &app.screen {
crate::app::Screen::ConfirmHostKeyTrust {
alias, hostname, ..
} => {
assert_eq!(alias, "h1");
assert_eq!(hostname, "db.example.com");
}
other => panic!("expected the trust dialog, got {other:?}"),
}
}
#[test]
fn a_prompt_that_named_no_host_asks_for_nothing_and_explains() {
let mut app = app_with_hosts(
"Host bast\n HostName bastion.example.com\n\
Host h1\n HostName db.example.com\n ProxyJump bast\n",
);
let marker = app
.env()
.paths()
.expect("sandboxed paths")
.askpass_withheld_marker("h1");
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, b"").unwrap();
run_with(
&mut app,
vec![result("h1", needs_password_from(Some("db.example.com")))],
);
assert!(
app.password_prompt.is_none(),
"a dialog here would ask for something that cannot be used"
);
assert!(
!marker.exists(),
"reading takes the marker, so it cannot suppress a later dialog"
);
let said = app
.status_center
.status()
.map(|s| s.text.clone())
.or_else(|| app.status_center.toast().map(|t| t.text.clone()))
.expect("the reason is reported");
assert!(said.contains("h1"), "got: {said}");
}
#[test]
fn a_jump_host_refusal_does_not_ask_for_the_targets_password() {
let mut app = app_with_hosts(
"Host bast\n HostName bastion.example.com\n\
Host h1\n HostName db.example.com\n ProxyJump bast\n",
);
run_with(
&mut app,
vec![result(
"h1",
needs_password_from(Some("bastion.example.com")),
)],
);
assert_eq!(app.screen, crate::app::Screen::HostList);
assert!(app.password_prompt.is_none(), "no dialog for the target");
let said = app
.status_center
.status()
.map(|s| s.text.clone())
.or_else(|| app.status_center.toast().map(|t| t.text.clone()))
.expect("the failure is reported");
assert!(said.contains("h1"), "got: {said}");
}
#[test]
fn the_targets_own_refusal_behind_a_jump_host_still_asks() {
let mut app = app_with_hosts(
"Host bast\n HostName bastion.example.com\n\
Host h1\n HostName db.example.com\n ProxyJump bast\n",
);
run_with(
&mut app,
vec![result("h1", needs_password_from(Some("db.example.com")))],
);
assert_eq!(app.screen, crate::app::Screen::PasswordPrompt);
assert_eq!(
app.password_prompt.as_ref().map(|s| s.alias.as_str()),
Some("h1")
);
}
#[test]
fn a_refusal_naming_nobody_still_asks_on_a_host_with_no_jump() {
let mut app = app_with_hosts("Host h1\n HostName db.example.com\n");
run_with(&mut app, vec![result("h1", needs_password_from(None))]);
assert_eq!(app.screen, crate::app::Screen::PasswordPrompt);
}
#[test]
fn an_unknown_jump_host_key_is_not_trusted_under_the_targets_name() {
let mut app = app_with_hosts(
"Host bast\n HostName bastion.example.com\n\
Host h1\n HostName db.example.com\n ProxyJump bast\n",
);
run_with(
&mut app,
vec![result(
"h1",
unknown_host_key_from(Some("bastion.example.com")),
)],
);
assert_eq!(app.screen, crate::app::Screen::HostList);
let said = app
.status_center
.status()
.map(|s| s.text.clone())
.or_else(|| app.status_center.toast().map(|t| t.text.clone()))
.expect("the failure is reported");
assert!(said.contains("h1"), "got: {said}");
}
#[test]
fn a_bracketed_host_and_port_still_matches_the_target() {
let mut app = app_with_hosts(
"Host bast\n HostName bastion.example.com\n\
Host h1\n HostName db.example.com\n Port 2222\n ProxyJump bast\n",
);
run_with(
&mut app,
vec![result(
"h1",
unknown_host_key_from(Some("[db.example.com]:2222")),
)],
);
assert!(
matches!(app.screen, crate::app::Screen::ConfirmHostKeyTrust { .. }),
"got: {:?}",
app.screen
);
}
#[test]
fn a_dialog_waits_while_the_user_is_in_a_form() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n");
app.set_screen(crate::app::Screen::AddHost);
run_with(&mut app, vec![result("h1", needs_password_from(None))]);
assert_eq!(app.screen, crate::app::Screen::AddHost, "the form stands");
assert!(app.password_prompt.is_none());
assert_eq!(
app.keys.push().pending_prompts.len(),
1,
"the host keeps its turn"
);
app.set_screen(crate::app::Screen::HostList);
assert!(drain_next_key_push_prompt(&mut app));
assert_eq!(app.screen, crate::app::Screen::PasswordPrompt);
}
#[test]
fn a_configured_source_that_did_not_deliver_gets_no_prompt() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n # purple:askpass bw:item\n");
run_with(&mut app, vec![result("h1", needs_password_from(None))]);
assert_eq!(app.screen, crate::app::Screen::HostList);
assert!(app.password_prompt.is_none());
let status = app.status_center.status().expect("sticky failure");
assert!(status.is_error(), "got: {}", status.text);
}
#[test]
fn a_keychain_source_still_gets_the_prompt() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n # purple:askpass keychain\n");
run_with(&mut app, vec![result("h1", needs_password_from(None))]);
assert_eq!(app.screen, crate::app::Screen::PasswordPrompt);
assert!(
app.password_prompt.as_ref().unwrap().source_is_keychain,
"a keychain host needs no second config write on submit"
);
}
#[test]
fn hosts_that_landed_are_reported_before_the_dialog_opens() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\nHost h2\n HostName 2.2.2.2\n");
run_with(
&mut app,
vec![
result("h1", KeyPushOutcome::AlreadyPresent),
result("h2", needs_password_from(None)),
],
);
let toast = app.status_center.toast().expect("summary toast");
assert!(toast.text.contains('1'), "got: {}", toast.text);
assert_eq!(app.screen, crate::app::Screen::PasswordPrompt);
}
#[test]
fn queued_hosts_are_answered_one_at_a_time_in_run_order() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\nHost h2\n HostName 2.2.2.2\n");
run_with(
&mut app,
vec![
result("h1", needs_password_from(None)),
result("h2", needs_password_from(None)),
],
);
assert_eq!(
app.password_prompt.as_ref().map(|s| s.alias.as_str()),
Some("h1"),
"the first host of the run asks first"
);
assert_eq!(app.keys.push().pending_prompts.len(), 1, "h2 still waits");
app.password_prompt = None;
app.screen = crate::app::Screen::HostList;
assert!(drain_next_key_push_prompt(&mut app));
assert_eq!(
app.password_prompt.as_ref().map(|s| s.alias.as_str()),
Some("h2")
);
assert!(app.keys.push().pending_prompts.is_empty());
assert!(!drain_next_key_push_prompt(&mut app), "queue is empty");
}
#[test]
fn no_dialog_opens_over_another_one() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n");
app.keys.push_mut().key_path = "~/.ssh/id_test".to_string();
app.open_password_prompt(
"h1",
crate::app::PendingRetry::FileBrowserListing {
alias: "h1".into(),
path: "/".into(),
},
false,
);
app.keys
.push_mut()
.pending_prompts
.push_back(crate::app::KeyPushPrompt::Password {
alias: "h1".into(),
key_path: "~/.ssh/id_test".into(),
});
assert!(!drain_next_key_push_prompt(&mut app));
assert_eq!(app.keys.push().pending_prompts.len(), 1, "the host waits");
assert!(matches!(
app.password_prompt.as_ref().map(|s| s.retry.clone()),
Some(crate::app::PendingRetry::FileBrowserListing { .. })
));
}
#[test]
fn no_dialog_opens_while_a_run_is_in_flight() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n");
app.keys
.push_mut()
.pending_prompts
.push_back(crate::app::KeyPushPrompt::Password {
alias: "h1".into(),
key_path: "~/.ssh/id_test".into(),
});
app.keys.push_mut().expected_count = 1;
assert!(!drain_next_key_push_prompt(&mut app));
assert_eq!(app.keys.push().pending_prompts.len(), 1);
}
#[test]
fn a_vanished_key_drops_only_its_own_question() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\nHost h2\n HostName 2.2.2.2\n");
app.keys
.push_mut()
.pending_prompts
.push_back(crate::app::KeyPushPrompt::Password {
alias: "h1".into(),
key_path: "~/.ssh/id_gone".into(),
});
app.keys
.push_mut()
.pending_prompts
.push_back(crate::app::KeyPushPrompt::Password {
alias: "h2".into(),
key_path: "~/.ssh/id_test".into(),
});
assert!(drain_next_key_push_prompt(&mut app), "h2 keeps its turn");
assert_eq!(
app.password_prompt.as_ref().map(|s| s.alias.as_str()),
Some("h2")
);
assert!(app.keys.push().pending_prompts.is_empty());
}
#[test]
fn a_queued_question_is_answered_for_the_key_it_was_queued_for() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n");
app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
name: "id_other".into(),
display_path: "~/.ssh/id_other".into(),
key_type: "ED25519".into(),
bits: "256".into(),
fingerprint: String::new(),
comment: String::new(),
linked_hosts: vec![],
bishop_art: String::new(),
strength_score: 95,
encrypted: false,
agent_loaded: false,
is_certificate: false,
mtime_ts: None,
});
app.keys
.push_mut()
.pending_prompts
.push_back(crate::app::KeyPushPrompt::Password {
alias: "h1".into(),
key_path: "~/.ssh/id_test".into(),
});
app.keys.push_mut().key_path = "~/.ssh/id_other".to_string();
assert!(drain_next_key_push_prompt(&mut app));
let retry = app.password_prompt.as_ref().map(|s| s.retry.clone());
assert!(
matches!(
retry,
Some(crate::app::PendingRetry::KeyPush { ref key_path, .. })
if key_path == "~/.ssh/id_test"
),
"got: {retry:?}"
);
}
#[test]
fn a_changed_host_key_stays_a_plain_failure() {
let mut app = app_with_hosts("Host h1\n HostName 1.1.1.1\n");
run_with(
&mut app,
vec![result(
"h1",
KeyPushOutcome::Failed("Host key verification failed.".into()),
)],
);
assert_eq!(app.screen, crate::app::Screen::HostList);
assert!(app.keys.push().pending_prompts.is_empty());
assert!(app.status_center.status().expect("sticky").is_error());
}
#[test]
fn finalize_appended_refreshes_keys_against_override_dir_not_real_home() {
let mut app = make_app();
app.keys.push_mut().expected_count = 1;
app.keys.list_mut().push(crate::ssh_keys::SshKeyInfo {
name: "stale".into(),
display_path: "~/.ssh/stale".into(),
key_type: "ED25519".into(),
bits: "256".into(),
fingerprint: String::new(),
comment: String::new(),
linked_hosts: vec![],
bishop_art: String::new(),
strength_score: 90,
encrypted: false,
agent_loaded: false,
is_certificate: false,
mtime_ts: None,
});
handle_key_push_result(&mut app, 1, result("h", KeyPushOutcome::Appended));
assert!(
app.keys.list().is_empty(),
"discover_keys against an empty override dir should return zero keys"
);
assert_eq!(app.keys.list_state().selected(), None);
}
}