Skip to main content

purple_ssh/app/
password_prompt.rs

1//! State behind the in-TUI password prompt and the host key trust dialog.
2//!
3//! A background ssh (key push, file browser listing) can never reach the
4//! terminal, so a host that wants a password or is not in `known_hosts`
5//! yet asks through the TUI instead. Both dialogs carry a `PendingRetry`
6//! that names the operation to run again once the user has answered.
7
8use std::collections::HashMap;
9
10use super::App;
11
12/// Longest password the prompt accepts. Far above any real password and
13/// keeps a held-down key from growing the buffer without bound.
14pub const PASSWORD_MAX_CHARS: usize = 512;
15
16/// The operation to run again after the user answered a dialog. One
17/// variant per flow; each re-queues through the channel that flow owns.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum PendingRetry {
20    /// Push the key at `key_path` to this one host again. The key is named
21    /// by its path, not its position: a successful push rebuilds the key
22    /// list, and an index into the old list could then point at another key.
23    KeyPush { key_path: String, alias: String },
24    /// List `path` on the open file browser again. An empty path means the
25    /// remote home lookup itself failed and runs first.
26    FileBrowserListing { alias: String, path: String },
27    /// Start this host's tunnel again.
28    Tunnel { alias: String },
29}
30
31impl PendingRetry {
32    pub fn alias(&self) -> &str {
33        match self {
34            PendingRetry::KeyPush { alias, .. }
35            | PendingRetry::FileBrowserListing { alias, .. }
36            | PendingRetry::Tunnel { alias } => alias,
37        }
38    }
39
40    /// Short name for the log.
41    pub fn kind_label(&self) -> &'static str {
42        match self {
43            PendingRetry::KeyPush { .. } => "key_push",
44            PendingRetry::FileBrowserListing { .. } => "file_browser",
45            PendingRetry::Tunnel { .. } => "tunnel",
46        }
47    }
48
49    /// The flow a security key PIN typed for this retry belongs to.
50    pub fn pin_flow(&self) -> crate::security_key::PinFlow {
51        match self {
52            PendingRetry::KeyPush { .. } => crate::security_key::PinFlow::KeyPush,
53            PendingRetry::FileBrowserListing { .. } => crate::security_key::PinFlow::FileBrowser,
54            PendingRetry::Tunnel { .. } => crate::security_key::PinFlow::Tunnel,
55        }
56    }
57}
58
59/// Which field of the password prompt has focus.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum PasswordPromptField {
62    #[default]
63    Password,
64    Remember,
65}
66
67impl PasswordPromptField {
68    pub fn next(self) -> Self {
69        match self {
70            PasswordPromptField::Password => PasswordPromptField::Remember,
71            PasswordPromptField::Remember => PasswordPromptField::Password,
72        }
73    }
74}
75
76/// Payload of `Screen::PasswordPrompt`. Lives beside the screen instead of
77/// inside the variant so the secret is never cloned on every render.
78#[derive(Clone, PartialEq, Eq)]
79pub struct PasswordPromptState {
80    pub alias: String,
81    /// True when the alias already resolves to `keychain`, so a "remember"
82    /// submit stores the password without writing the config again.
83    pub source_is_keychain: bool,
84    pub input: String,
85    pub remember: bool,
86    pub focus: PasswordPromptField,
87    pub retry: PendingRetry,
88    /// Set when the prompt asks for a security key PIN instead: the key file
89    /// it belongs to. A PIN is never stored in the keychain.
90    pub security_key: Option<String>,
91    /// Alias of the per-host tunnel list the prompt opened over, which it
92    /// returns to.
93    pub return_to_tunnel_list: Option<String>,
94}
95
96// Hand-written so a stray `{:?}` can never print the typed password. Shows
97// whether one has been entered, never what it is.
98impl std::fmt::Debug for PasswordPromptState {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("PasswordPromptState")
101            .field("alias", &self.alias)
102            .field("source_is_keychain", &self.source_is_keychain)
103            .field("input_len", &self.input.chars().count())
104            .field("remember", &self.remember)
105            .field("focus", &self.focus)
106            .field("retry", &self.retry)
107            .field("security_key", &self.security_key)
108            .field("return_to_tunnel_list", &self.return_to_tunnel_list)
109            .finish()
110    }
111}
112
113impl PasswordPromptState {
114    pub fn new(alias: String, source_is_keychain: bool, retry: PendingRetry) -> Self {
115        Self {
116            alias,
117            source_is_keychain,
118            input: String::new(),
119            remember: true,
120            focus: PasswordPromptField::Password,
121            retry,
122            security_key: None,
123            return_to_tunnel_list: None,
124        }
125    }
126
127    /// The prompt for the PIN of the security key at `key_path`.
128    pub fn security_key_pin(alias: String, key_path: String, retry: PendingRetry) -> Self {
129        Self {
130            remember: false,
131            security_key: Some(key_path),
132            ..Self::new(alias, false, retry)
133        }
134    }
135}
136
137/// Passwords typed with "remember" off, keyed by alias, kept until purple
138/// exits. Every later background operation on the alias uses the entry
139/// through the one-shot askpass channel. Nothing here reaches disk.
140pub type SessionPasswords = HashMap<String, String>;
141
142/// True when a password prompt may open for a host with this source: no
143/// source at all, or the OS keychain. Any other source failing means the
144/// source itself did not deliver, which a typed password cannot fix.
145pub fn source_allows_prompt(source: Option<&str>) -> bool {
146    matches!(source, None | Some("keychain"))
147}
148
149impl App {
150    /// The password source a background ssh for `alias` resolves to: the
151    /// host's own `# purple:askpass`, else the global default. Mirrors the
152    /// lookup the askpass subprocess performs.
153    pub(crate) fn askpass_source_for(&self, alias: &str) -> Option<String> {
154        self.hosts_state
155            .list
156            .iter()
157            .find(|h| h.alias == alias)
158            .and_then(|h| h.askpass.clone())
159            .or_else(|| crate::preferences::load_askpass_default(self.env.paths()))
160    }
161
162    /// True when ssh's refusal can be attributed to `alias` itself rather
163    /// than to a jump host on the way there. Without a bastion there is only
164    /// one host that could have refused, so the name ssh printed does not
165    /// matter. With one, ssh names the hop that refused and only the
166    /// target's own refusal is ours to answer: a dialog for a bastion would
167    /// write that bastion's password onto the target. A `ProxyCommand`
168    /// counts as a bastion as much as a `ProxyJump` does, since it reaches
169    /// a machine of its own that purple cannot name.
170    pub(crate) fn refusal_is_this_host(&self, alias: &str, named: Option<&str>) -> bool {
171        let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
172            // Not in the list, so there is no route to read. Treat it as
173            // the plain single-hop case.
174            return true;
175        };
176        if host.proxy_jump.trim().is_empty() && !host.has_proxy_command {
177            return true;
178        }
179        // Behind a jump host, so the name decides. A refusal naming nobody
180        // could have come from either end.
181        let Some(named) = named else {
182            return false;
183        };
184        let named = crate::connection::bare_host(named);
185        let target = if host.hostname.trim().is_empty() {
186            alias
187        } else {
188            host.hostname.trim()
189        };
190        named.eq_ignore_ascii_case(target)
191    }
192
193    /// True while a dialog is waiting for an answer. A second one must not
194    /// open over it: the retry the first was holding would be lost with no
195    /// trace, and the user would answer a question they never saw asked.
196    pub(crate) fn dialog_open(&self) -> bool {
197        self.password_prompt.is_some()
198            || matches!(
199                self.screen,
200                super::Screen::PasswordPrompt | super::Screen::ConfirmHostKeyTrust { .. }
201            )
202    }
203
204    /// True when a background answer may take the screen. No other dialog
205    /// may be waiting. The user has to be on a page a dialog returns to
206    /// rather than inside a form, a picker or an overlay. Every typing mode
207    /// that lives inside those pages has to be closed as well: the host
208    /// list's search, its tag field and the jump bar. None of the three
209    /// changes `self.screen`, so the screen alone does not rule them out,
210    /// and the jump bar even takes every key ahead of the screen match.
211    /// Opening over one of them would send the rest of what they type into
212    /// the password field, and the Enter that ends a search would submit
213    /// the fragment as a password. Anywhere else the question waits until
214    /// they come back.
215    pub(crate) fn can_open_dialog(&self) -> bool {
216        !self.dialog_open()
217            && self.search.query().is_none()
218            && self.tags.input().is_none()
219            && self.jump.is_none()
220            && self.tunnels.pending_delete().is_none()
221            && self.snippets.pending_delete().is_none()
222            && !self.file_browser_is_busy()
223            && matches!(
224                self.screen,
225                super::Screen::HostList | super::Screen::FileBrowser { .. }
226            )
227    }
228
229    /// True while the file browser has a question or a transfer of its own
230    /// on screen. All three render inside `Screen::FileBrowser`, so a
231    /// dialog opening over them would take the answer meant for them.
232    fn file_browser_is_busy(&self) -> bool {
233        self.file_browser_session.as_ref().is_some_and(|fb| {
234            fb.confirm_copy.is_some() || fb.transferring.is_some() || fb.transfer_error.is_some()
235        })
236    }
237
238    /// Drop a prompt whose screen moved on without it. `dialog_open` reads
239    /// the state rather than the screen, so a stranded one would refuse
240    /// every later question for the rest of the session. Returns true when
241    /// one was cleared.
242    pub(crate) fn drop_stranded_password_prompt(&mut self) -> bool {
243        if self.password_prompt.is_none() || matches!(self.screen, super::Screen::PasswordPrompt) {
244            return false;
245        }
246        let alias = self
247            .password_prompt
248            .take()
249            .map(|s| s.alias)
250            .unwrap_or_default();
251        log::warn!("[purple] password prompt: dropped stranded state for alias={alias}");
252        self.notify_warning(crate::messages::askpass::prompt_cancelled(&alias));
253        true
254    }
255
256    /// True when purple already handed `alias` a password and the server
257    /// still refused: either a source resolves for it or a password typed
258    /// this session is on file. The prompt then says so, instead of
259    /// reopening as though nothing had been tried.
260    pub(crate) fn password_was_supplied_for(&self, alias: &str) -> bool {
261        self.askpass_source_for(alias).is_some() || self.session_passwords.contains_key(alias)
262    }
263
264    /// Open the password prompt for `alias` with `retry` queued behind it.
265    /// A password the server just rejected is dropped first, so the retry
266    /// after this prompt carries the new one. `supplied_before` says whether
267    /// an earlier answer was already refused, which the toast reports.
268    pub(crate) fn open_password_prompt(
269        &mut self,
270        alias: &str,
271        retry: PendingRetry,
272        supplied_before: bool,
273    ) {
274        self.session_passwords.remove(alias);
275        let source_is_keychain = self.askpass_source_for(alias).as_deref() == Some("keychain");
276        log::debug!(
277            "[purple] password prompt: open alias={} keychain_source={} supplied_before={} retry={}",
278            alias,
279            source_is_keychain,
280            supplied_before,
281            retry.kind_label()
282        );
283        self.password_prompt = Some(PasswordPromptState::new(
284            alias.to_string(),
285            source_is_keychain,
286            retry,
287        ));
288        self.set_screen(super::Screen::PasswordPrompt);
289        if supplied_before {
290            self.notify_warning(crate::messages::askpass::password_rejected(alias));
291        }
292    }
293
294    /// The password typed for `alias` this session, if the user chose not to
295    /// store it. Handed to every later background ssh for that host through
296    /// the one-shot askpass channel, so a host is asked once and not once
297    /// per directory.
298    pub(crate) fn session_password_for(&self, alias: &str) -> Option<String> {
299        self.session_passwords.get(alias).cloned()
300    }
301
302    /// Build the SSH context a background operation on `alias` runs with:
303    /// the caller's resolved askpass source, any password typed this
304    /// session, the Bitwarden token and whether a tunnel is already open.
305    /// `trust_new_host_key` is false; only a retry after the trust dialog
306    /// sets it.
307    pub(crate) fn ssh_context_for(
308        &self,
309        alias: impl Into<String>,
310        askpass: Option<String>,
311    ) -> crate::ssh_context::OwnedSshContext {
312        let alias = alias.into();
313        crate::ssh_context::OwnedSshContext {
314            session_password: self.session_password_for(&alias),
315            has_tunnel: self.tunnels.active_contains(&alias),
316            alias,
317            config_path: self.reload.config_path().to_path_buf(),
318            askpass,
319            bw_session: self.bw_session.clone(),
320            trust_new_host_key: false,
321            security_key: self.security_key_confirmed_run(),
322            env: std::sync::Arc::clone(&self.env),
323        }
324    }
325
326    /// `ssh_context_for` for a file browser listing, which may ask for a
327    /// missing security key PIN and carries the trial PIN typed for it.
328    pub(crate) fn file_browser_ssh_context(
329        &self,
330        alias: impl Into<String>,
331        askpass: Option<String>,
332    ) -> crate::ssh_context::OwnedSshContext {
333        let mut ctx = self.ssh_context_for(alias, askpass);
334        ctx.security_key =
335            self.security_key_run_for(&ctx.alias, crate::security_key::PinFlow::FileBrowser);
336        ctx
337    }
338
339    /// Open the trust dialog for `alias` with `retry` queued behind it. The
340    /// dialog names the configured HostName, falling back to the alias.
341    pub(crate) fn open_host_key_trust(&mut self, alias: &str, retry: PendingRetry) {
342        let hostname = self
343            .hosts_state
344            .list
345            .iter()
346            .find(|h| h.alias == alias)
347            .map(|h| h.hostname.clone())
348            .filter(|h| !h.is_empty())
349            .unwrap_or_else(|| alias.to_string());
350        log::debug!(
351            "[purple] host key trust: open alias={} hostname={}",
352            alias,
353            hostname
354        );
355        self.set_screen(super::Screen::ConfirmHostKeyTrust {
356            alias: alias.to_string(),
357            hostname,
358            retry,
359        });
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::app::Screen;
367    use crate::ssh_config::model::SshConfigFile;
368
369    fn app_with(config: &str) -> App {
370        let scratch = tempfile::tempdir().expect("tempdir").keep();
371        let cfg = SshConfigFile {
372            elements: SshConfigFile::parse_content(config),
373            path: scratch.join("config"),
374            crlf: false,
375            bom: false,
376        };
377        App::new(cfg)
378    }
379
380    #[test]
381    fn source_allows_prompt_only_for_none_and_keychain() {
382        assert!(source_allows_prompt(None));
383        assert!(source_allows_prompt(Some("keychain")));
384        for other in [
385            "bw:item",
386            "op://v/i/f",
387            "proton:v/i/f",
388            "pass:x",
389            "vault:s#p",
390            "my-cmd %h",
391        ] {
392            assert!(!source_allows_prompt(Some(other)), "{other}");
393        }
394    }
395
396    #[test]
397    fn field_focus_cycles_between_the_two_fields() {
398        assert_eq!(
399            PasswordPromptField::Password.next(),
400            PasswordPromptField::Remember
401        );
402        assert_eq!(
403            PasswordPromptField::Remember.next(),
404            PasswordPromptField::Password
405        );
406    }
407
408    #[test]
409    fn new_state_defaults_to_remember_on_and_password_focus() {
410        let s = PasswordPromptState::new(
411            "h".into(),
412            false,
413            PendingRetry::KeyPush {
414                key_path: "~/.ssh/id_test".into(),
415                alias: "h".into(),
416            },
417        );
418        assert!(s.remember);
419        assert_eq!(s.focus, PasswordPromptField::Password);
420        assert!(s.input.is_empty());
421    }
422
423    #[test]
424    fn pending_retry_alias_names_the_host() {
425        let a = PendingRetry::KeyPush {
426            key_path: "~/.ssh/id_test".into(),
427            alias: "web".into(),
428        };
429        let b = PendingRetry::FileBrowserListing {
430            alias: "db".into(),
431            path: "/".into(),
432        };
433        assert_eq!(a.alias(), "web");
434        assert_eq!(b.alias(), "db");
435    }
436
437    #[test]
438    fn askpass_source_prefers_the_host_comment() {
439        let app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass bw:item\n");
440        assert_eq!(app.askpass_source_for("h").as_deref(), Some("bw:item"));
441        assert_eq!(app.askpass_source_for("missing"), None);
442    }
443
444    #[test]
445    fn askpass_source_falls_back_to_the_global_default() {
446        let app = app_with("Host h\n  HostName 1.1.1.1\n");
447        crate::preferences::save_askpass_default(app.env().paths(), "keychain").unwrap();
448        assert_eq!(app.askpass_source_for("h").as_deref(), Some("keychain"));
449    }
450
451    #[test]
452    fn open_password_prompt_sets_state_screen_and_drops_rejected_password() {
453        let mut app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass keychain\n");
454        app.session_passwords
455            .insert("h".to_string(), "wrong".to_string());
456        let retry = PendingRetry::KeyPush {
457            key_path: "~/.ssh/id_test".into(),
458            alias: "h".into(),
459        };
460        app.open_password_prompt("h", retry.clone(), false);
461        assert_eq!(app.screen, Screen::PasswordPrompt);
462        let state = app.password_prompt.as_ref().expect("prompt state");
463        assert_eq!(state.alias, "h");
464        assert!(state.source_is_keychain);
465        assert_eq!(state.retry, retry);
466        assert!(
467            !app.session_passwords.contains_key("h"),
468            "a rejected session password must not survive into the retry"
469        );
470    }
471
472    #[test]
473    fn a_second_dialog_is_seen_as_already_open() {
474        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
475        assert!(!app.dialog_open());
476        app.open_password_prompt(
477            "h",
478            PendingRetry::KeyPush {
479                key_path: "~/.ssh/id_test".into(),
480                alias: "h".into(),
481            },
482            false,
483        );
484        assert!(app.dialog_open());
485        app.password_prompt = None;
486        app.screen = Screen::HostList;
487        app.open_host_key_trust(
488            "h",
489            PendingRetry::FileBrowserListing {
490                alias: "h".into(),
491                path: "/".into(),
492            },
493        );
494        assert!(app.dialog_open());
495    }
496
497    #[test]
498    fn a_reopened_prompt_says_the_last_password_was_refused() {
499        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
500        app.session_passwords
501            .insert("h".to_string(), "wrong".to_string());
502        assert!(app.password_was_supplied_for("h"));
503        let supplied = app.password_was_supplied_for("h");
504        app.open_password_prompt(
505            "h",
506            PendingRetry::KeyPush {
507                key_path: "~/.ssh/id_test".into(),
508                alias: "h".into(),
509            },
510            supplied,
511        );
512        let toast = app.status_center.toast().expect("toast");
513        assert!(toast.text.contains('h'), "got: {}", toast.text);
514        // The refused password is gone, so the retry carries the new one.
515        assert!(!app.session_passwords.contains_key("h"));
516    }
517
518    #[test]
519    fn a_keychain_source_counts_as_a_password_already_supplied() {
520        let app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass keychain\n");
521        assert!(app.password_was_supplied_for("h"));
522        let bare = app_with("Host h\n  HostName 1.1.1.1\n");
523        assert!(!bare.password_was_supplied_for("h"));
524    }
525
526    #[test]
527    fn ssh_context_for_carries_the_session_password_and_defaults_to_strict_checking() {
528        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
529        app.session_passwords
530            .insert("h".to_string(), "hunter2".to_string());
531        let ctx = app.ssh_context_for("h", Some("keychain".to_string()));
532        assert_eq!(ctx.alias, "h");
533        assert_eq!(ctx.askpass.as_deref(), Some("keychain"));
534        assert_eq!(ctx.session_password.as_deref(), Some("hunter2"));
535        assert!(!ctx.trust_new_host_key);
536        assert!(!ctx.has_tunnel);
537    }
538
539    #[test]
540    fn ssh_context_for_leaves_the_password_empty_for_another_host() {
541        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
542        app.session_passwords
543            .insert("other".to_string(), "hunter2".to_string());
544        assert!(app.ssh_context_for("h", None).session_password.is_none());
545    }
546
547    #[test]
548    fn open_host_key_trust_names_the_hostname_and_falls_back_to_alias() {
549        let mut app = app_with("Host h\n  HostName db.example.com\n");
550        let retry = PendingRetry::FileBrowserListing {
551            alias: "h".into(),
552            path: String::new(),
553        };
554        app.open_host_key_trust("h", retry.clone());
555        assert_eq!(
556            app.screen,
557            Screen::ConfirmHostKeyTrust {
558                alias: "h".into(),
559                hostname: "db.example.com".into(),
560                retry: retry.clone(),
561            }
562        );
563        app.open_host_key_trust("ghost", retry);
564        match &app.screen {
565            Screen::ConfirmHostKeyTrust { hostname, .. } => assert_eq!(hostname, "ghost"),
566            other => panic!("expected trust dialog, got {other:?}"),
567        }
568    }
569
570    #[test]
571    fn a_proxy_command_counts_as_a_bastion_in_front_of_the_host() {
572        // `ProxyCommand ssh -W %h:%p bastion` reaches a second machine just
573        // as `ProxyJump bastion` does. A refusal naming nobody could have
574        // come from either end, so it is not this host's to answer.
575        let app =
576            app_with("Host h\n  HostName db.example.com\n  ProxyCommand ssh -W %h:%p bastion\n");
577        assert!(!app.refusal_is_this_host("h", None));
578        assert!(!app.refusal_is_this_host("h", Some("bastion.example.com")));
579        assert!(app.refusal_is_this_host("h", Some("db.example.com")));
580    }
581
582    #[test]
583    fn a_proxy_command_set_to_none_leaves_the_host_direct() {
584        // `ProxyCommand none` is how a wildcard block is opted out of, so it
585        // puts no machine in front of this host.
586        let app = app_with("Host h\n  HostName db.example.com\n  ProxyCommand none\n");
587        assert!(app.refusal_is_this_host("h", None));
588    }
589
590    #[test]
591    fn a_dialog_waits_while_the_host_list_search_is_open() {
592        // The search input lives inside Screen::HostList, so the screen
593        // alone does not say the user is free to be asked a question.
594        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
595        app.screen = Screen::HostList;
596        assert!(app.can_open_dialog());
597        app.search.set_query(Some("web".to_string()));
598        assert!(
599            !app.can_open_dialog(),
600            "a half typed search must keep the screen"
601        );
602        app.search.set_query(None);
603        assert!(app.can_open_dialog());
604    }
605
606    #[test]
607    fn a_dialog_waits_while_the_jump_bar_is_open() {
608        // The jump bar takes every key ahead of the screen match, so a
609        // dialog opening under it would collect what the user types at it.
610        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
611        app.screen = Screen::HostList;
612        app.open_jump(crate::app::JumpMode::Hosts);
613        assert!(!app.can_open_dialog(), "the jump bar keeps the screen");
614    }
615
616    #[test]
617    fn a_dialog_waits_while_another_confirm_is_on_screen() {
618        // A tunnel or snippet delete confirm renders inside the host list
619        // and answers with y or n. Those keys must not reach a password
620        // field that appeared under them.
621        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
622        app.screen = Screen::HostList;
623        app.tunnels.pending_delete = Some(0);
624        assert!(!app.can_open_dialog(), "a tunnel confirm keeps the screen");
625        app.tunnels.take_pending_delete();
626        assert!(app.can_open_dialog());
627
628        app.snippets.pending_delete = Some(0);
629        assert!(!app.can_open_dialog(), "a snippet confirm keeps the screen");
630        app.snippets.pending_delete = None;
631        assert!(app.can_open_dialog());
632    }
633
634    #[test]
635    fn a_dialog_waits_while_the_tag_input_is_open() {
636        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
637        app.screen = Screen::HostList;
638        app.tags.open_tag_input("web".to_string());
639        assert!(
640            !app.can_open_dialog(),
641            "a half typed tag must keep the screen"
642        );
643    }
644
645    #[test]
646    fn a_refusal_is_parsed_out_of_stderr_before_it_is_attributed() {
647        // The seam: what ssh printed, through the parser, into the decision
648        // whether this host may be asked for a password.
649        let app = app_with("Host h\n  HostName target.example.com\n  ProxyJump bastion\n");
650        let bastion = "ops@bastion.example.com: Permission denied (publickey,password).\n";
651        let named = crate::connection::denied_host(bastion);
652        assert_eq!(named, Some("bastion.example.com"));
653        assert!(
654            !app.refusal_is_this_host("h", named),
655            "the jump host's refusal is not the target's to answer"
656        );
657
658        let target = "ops@target.example.com: Permission denied (publickey,password).\n";
659        let named = crate::connection::denied_host(target);
660        assert!(app.refusal_is_this_host("h", named));
661    }
662
663    #[test]
664    fn a_bracketed_host_from_the_trust_line_matches_the_target() {
665        let app = app_with("Host h\n  HostName 10.0.0.1\n  ProxyJump bastion\n");
666        let stderr = "No ED25519 host key is known for [10.0.0.1]:2222 and you have requested strict checking.\n";
667        let named = crate::connection::unknown_host_key_host(stderr);
668        assert_eq!(named, Some("[10.0.0.1]:2222"));
669        assert!(app.refusal_is_this_host("h", named));
670    }
671}