Skip to main content

badterm_core/
autologin.rs

1//! Logging in with the entry's stored user name and password: answering the
2//! BBS's prompts by itself, or one step at a time on the user's request
3//! (Alt-L).
4//!
5//! A prompt is the text left of the cursor on its line once the BBS stops
6//! sending. "Login:", "User name:", "Handle?", Synchronet's "NN:" and the
7//! like get the user name; "Password:" (or "PW:") gets the password, but
8//! only after the user name, so a new-user form asking for a password is
9//! never answered. Each is sent once, and only in the first few minutes of a
10//! call.
11
12use std::time::{Duration, Instant};
13
14use crate::dialer::{Entry, Protocol};
15
16/// How long after connecting prompts are still answered.
17const WINDOW: Duration = Duration::from_secs(120);
18
19/// What to send next.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum Step {
22    User(String),
23    Password(String),
24}
25
26#[derive(Debug)]
27pub struct AutoLogin {
28    user: String,
29    password: String,
30    /// Answer prompts without being asked.
31    automatic: bool,
32    sent_user: bool,
33    sent_password: bool,
34    until: Option<Instant>,
35}
36
37impl AutoLogin {
38    pub fn new(entry: &Entry) -> AutoLogin {
39        AutoLogin {
40            user: entry.username.clone(),
41            password: entry.password.clone(),
42            // SSH and RLogin log in as part of the protocol.
43            automatic: entry.auto_login && (entry.protocol.is_telnet() || entry.protocol == Protocol::Raw),
44            sent_user: false,
45            sent_password: false,
46            until: None,
47        }
48    }
49
50    /// The call is connected: start the time window.
51    pub fn connected(&mut self) {
52        self.until = Some(Instant::now() + WINDOW);
53    }
54
55    /// The answer for `prompt`, if it is a login prompt this login answers.
56    pub fn answer(&mut self, prompt: &str) -> Option<Step> {
57        if !self.automatic || self.until.is_none_or(|t| Instant::now() > t) {
58            return None;
59        }
60        if !self.sent_user && !self.user.is_empty() && is_user_prompt(prompt) {
61            return self.next_step();
62        }
63        if self.sent_user && !self.sent_password && !self.password.is_empty() && is_password_prompt(prompt) {
64            return self.next_step();
65        }
66        None
67    }
68
69    /// The next step, whatever is on the screen: Alt-L. `None` once both
70    /// have been sent (or there is nothing stored).
71    pub fn next_step(&mut self) -> Option<Step> {
72        if !self.sent_user && !self.user.is_empty() {
73            self.sent_user = true;
74            return Some(Step::User(self.user.clone()));
75        }
76        self.sent_user = true;
77        if !self.sent_password && !self.password.is_empty() {
78            self.sent_password = true;
79            return Some(Step::Password(self.password.clone()));
80        }
81        None
82    }
83
84    /// Whether there is anything stored to log in with.
85    pub fn has_login(&self) -> bool {
86        !self.user.is_empty() || !self.password.is_empty()
87    }
88}
89
90/// The prompt, lower-cased and without the colon (or question mark, or
91/// angle bracket) it ends with; `None` if it does not end like a prompt.
92fn prompt_text(prompt: &str) -> Option<String> {
93    let p = prompt.trim_end();
94    let p = p.strip_suffix([':', '?', '>'])?;
95    Some(p.trim().to_ascii_lowercase())
96}
97
98fn is_user_prompt(prompt: &str) -> bool {
99    let Some(p) = prompt_text(prompt) else { return false };
100    if p.contains("password") || p.contains("pass word") {
101        return false;
102    }
103    // The last words matter: "Enter your user name or NEW", "Login",
104    // "Handle", "NN" (Synchronet: name or number).
105    let words = ["login", "user", "username", "name", "handle", "alias", "nn", "logon", "callsign"];
106    let last: Vec<&str> = p.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()).collect();
107    let tail = &last[last.len().saturating_sub(4)..];
108    tail.iter().any(|w| words.contains(w)) && !tail.contains(&"real") && !tail.contains(&"first")
109}
110
111fn is_password_prompt(prompt: &str) -> bool {
112    let Some(p) = prompt_text(prompt) else { return false };
113    p.ends_with("password") || p.ends_with("pw") || p.ends_with("passwd") || p.ends_with("pass")
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn login(user: &str, password: &str) -> AutoLogin {
121        let mut a = AutoLogin::new(&Entry { username: user.into(), password: password.into(), ..Entry::default() });
122        a.connected();
123        a
124    }
125
126    #[test]
127    fn prompts() {
128        for p in ["Login: ", "Username:", "Enter your user name or 'NEW':", "Handle? ", "NN: ", "Alias>", "logon:"] {
129            assert!(is_user_prompt(p), "{p}");
130        }
131        for p in ["Password: ", "PW: ", "Enter your password:", "passwd:"] {
132            assert!(is_password_prompt(p), "{p}");
133            assert!(!is_user_prompt(p), "{p}");
134        }
135        for p in ["Press any key", "Login", "Your real name:", "First name?", "Choose a password for your new user:"] {
136            assert!(!is_user_prompt(p), "{p}");
137        }
138        assert!(!is_password_prompt("Choose a password for your new user name:"));
139    }
140
141    #[test]
142    fn answers_in_order_once() {
143        let mut a = login("sysop", "secret");
144        // A password prompt before the user name is not answered.
145        assert_eq!(a.answer("Password:"), None);
146        assert_eq!(a.answer("Login: "), Some(Step::User("sysop".into())));
147        assert_eq!(a.answer("Login: "), None);
148        assert_eq!(a.answer("Password: "), Some(Step::Password("secret".into())));
149        assert_eq!(a.answer("Password: "), None);
150        assert_eq!(a.next_step(), None);
151    }
152
153    #[test]
154    fn manual_steps_and_settings() {
155        let mut a = login("sysop", "secret");
156        assert_eq!(a.next_step(), Some(Step::User("sysop".into())));
157        assert_eq!(a.next_step(), Some(Step::Password("secret".into())));
158        assert_eq!(a.next_step(), None);
159
160        // Not before connecting, not when turned off, not over SSH.
161        let mut a = AutoLogin::new(&Entry { username: "u".into(), ..Entry::default() });
162        assert_eq!(a.answer("Login:"), None);
163        let off = Entry { username: "u".into(), auto_login: false, ..Entry::default() };
164        let mut a = AutoLogin::new(&off);
165        a.connected();
166        assert_eq!(a.answer("Login:"), None);
167        let ssh = Entry { username: "u".into(), protocol: Protocol::Ssh, ..Entry::default() };
168        let mut a = AutoLogin::new(&ssh);
169        a.connected();
170        assert_eq!(a.answer("Login:"), None);
171        assert!(a.has_login());
172    }
173}