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