Skip to main content

par_term_ssh/
history.rs

1//! Shell history scanner for SSH commands.
2
3use super::types::{SshHost, SshHostSource};
4use std::collections::HashSet;
5use std::path::Path;
6
7/// Scan shell history files for SSH commands.
8pub fn scan_history() -> Vec<SshHost> {
9    let mut hosts = Vec::new();
10    let mut seen = HashSet::new();
11
12    if let Some(home) = dirs::home_dir() {
13        let bash_hist = home.join(".bash_history");
14        if bash_hist.exists() {
15            scan_history_file(&bash_hist, false, &mut hosts, &mut seen);
16        }
17
18        let zsh_hist = home.join(".zsh_history");
19        if zsh_hist.exists() {
20            scan_history_file(&zsh_hist, true, &mut hosts, &mut seen);
21        }
22
23        let fish_hist = home.join(".local/share/fish/fish_history");
24        if fish_hist.exists() {
25            scan_fish_history(&fish_hist, &mut hosts, &mut seen);
26        }
27    }
28
29    hosts
30}
31
32fn scan_history_file(
33    path: &Path,
34    is_zsh: bool,
35    hosts: &mut Vec<SshHost>,
36    seen: &mut HashSet<String>,
37) {
38    let content = match std::fs::read_to_string(path) {
39        Ok(c) => c,
40        Err(_) => return,
41    };
42
43    for line in content.lines() {
44        let line = if is_zsh {
45            line.split_once(';').map(|(_, cmd)| cmd).unwrap_or(line)
46        } else {
47            line
48        };
49
50        if let Some(host) = parse_ssh_command(line) {
51            let key = host.connection_string();
52            if !seen.contains(&key) {
53                seen.insert(key);
54                hosts.push(host);
55            }
56        }
57    }
58}
59
60fn scan_fish_history(path: &Path, hosts: &mut Vec<SshHost>, seen: &mut HashSet<String>) {
61    let content = match std::fs::read_to_string(path) {
62        Ok(c) => c,
63        Err(_) => return,
64    };
65
66    for line in content.lines() {
67        let line = line.trim();
68        if let Some(cmd) = line.strip_prefix("- cmd: ")
69            && let Some(host) = parse_ssh_command(cmd)
70        {
71            let key = host.connection_string();
72            if !seen.contains(&key) {
73                seen.insert(key);
74                hosts.push(host);
75            }
76        }
77    }
78}
79
80/// Parse an SSH command line and extract host info.
81pub fn parse_ssh_command(line: &str) -> Option<SshHost> {
82    let parts: Vec<&str> = line.split_whitespace().collect();
83
84    let ssh_idx = parts.iter().position(|&p| p == "ssh")?;
85    let args = &parts[ssh_idx + 1..];
86
87    if args.is_empty() {
88        return None;
89    }
90
91    let mut port: Option<u16> = None;
92    let mut identity: Option<String> = None;
93    let mut target: Option<&str> = None;
94    let mut i = 0;
95
96    while i < args.len() {
97        match args[i] {
98            "-p" => {
99                if i + 1 < args.len() {
100                    port = args[i + 1].parse().ok();
101                    i += 2;
102                } else {
103                    i += 1;
104                }
105            }
106            "-i" => {
107                if i + 1 < args.len() {
108                    identity = Some(args[i + 1].to_string());
109                    i += 2;
110                } else {
111                    i += 1;
112                }
113            }
114            "-J" => {
115                i += 2;
116            }
117            arg if arg.starts_with('-') => {
118                let takes_value = matches!(
119                    arg,
120                    "-b" | "-c"
121                        | "-D"
122                        | "-E"
123                        | "-e"
124                        | "-F"
125                        | "-I"
126                        | "-L"
127                        | "-l"
128                        | "-m"
129                        | "-O"
130                        | "-o"
131                        | "-Q"
132                        | "-R"
133                        | "-S"
134                        | "-W"
135                        | "-w"
136                );
137                if takes_value && i + 1 < args.len() {
138                    i += 2;
139                } else {
140                    i += 1;
141                }
142            }
143            arg => {
144                if target.is_none() {
145                    target = Some(arg);
146                }
147                i += 1;
148            }
149        }
150    }
151
152    let target = target?;
153
154    if target.starts_with('/') || target.starts_with('.') || target.contains('=') {
155        return None;
156    }
157
158    let (user, hostname) = if let Some((u, h)) = target.split_once('@') {
159        (Some(u.to_string()), h.to_string())
160    } else {
161        (None, target.to_string())
162    };
163
164    if hostname.is_empty() || hostname.starts_with('-') {
165        return None;
166    }
167
168    Some(SshHost {
169        alias: hostname.clone(),
170        hostname: Some(hostname),
171        user,
172        port,
173        identity_file: identity,
174        proxy_jump: None,
175        source: SshHostSource::History,
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_parse_simple_ssh() {
185        let host = parse_ssh_command("ssh myhost.com").unwrap();
186        assert_eq!(host.alias, "myhost.com");
187        assert_eq!(host.user, None);
188        assert_eq!(host.port, None);
189    }
190
191    #[test]
192    fn test_parse_user_at_host() {
193        let host = parse_ssh_command("ssh deploy@myhost.com").unwrap();
194        assert_eq!(host.alias, "myhost.com");
195        assert_eq!(host.user.as_deref(), Some("deploy"));
196    }
197
198    #[test]
199    fn test_parse_with_port() {
200        let host = parse_ssh_command("ssh -p 2222 myhost.com").unwrap();
201        assert_eq!(host.port, Some(2222));
202    }
203
204    #[test]
205    fn test_parse_with_identity() {
206        let host = parse_ssh_command("ssh -i ~/.ssh/id_work myhost.com").unwrap();
207        assert_eq!(host.identity_file.as_deref(), Some("~/.ssh/id_work"));
208    }
209
210    #[test]
211    fn test_parse_complex_command() {
212        let host =
213            parse_ssh_command("ssh -p 2222 -i ~/.ssh/key deploy@server.example.com").unwrap();
214        assert_eq!(host.alias, "server.example.com");
215        assert_eq!(host.user.as_deref(), Some("deploy"));
216        assert_eq!(host.port, Some(2222));
217        assert_eq!(host.identity_file.as_deref(), Some("~/.ssh/key"));
218    }
219
220    #[test]
221    fn test_skip_non_ssh() {
222        assert!(parse_ssh_command("ls -la").is_none());
223        assert!(parse_ssh_command("git push").is_none());
224    }
225
226    #[test]
227    fn test_skip_ssh_only() {
228        assert!(parse_ssh_command("ssh").is_none());
229    }
230
231    #[test]
232    fn test_ssh_with_preceding_command() {
233        let host = parse_ssh_command("TERM=xterm ssh myhost.com").unwrap();
234        assert_eq!(host.alias, "myhost.com");
235    }
236}