Skip to main content

ssm_core/
import.rs

1use crate::config::Host;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5pub fn parse_ssh_config(path: &Path) -> Vec<Host> {
6    let content = match std::fs::read_to_string(path) {
7        Ok(c) => c,
8        Err(_) => return vec![],
9    };
10
11    let mut hosts = Vec::new();
12    let mut current_alias: Option<String> = None;
13    let mut hostname = String::new();
14    let mut user: Option<String> = None;
15    let mut port: u16 = 22;
16    let mut identity_file: Option<PathBuf> = None;
17
18    for line in content.lines() {
19        let trimmed = line.trim();
20
21        if trimmed.is_empty() || trimmed.starts_with('#') {
22            continue;
23        }
24
25        if let Some(rest) = trimmed.strip_prefix("Host ").or_else(|| trimmed.strip_prefix("Host\t"))
26        {
27            if let Some(alias) = current_alias.take()
28                && !hostname.is_empty()
29                && alias != "*"
30            {
31                hosts.push(Host {
32                    alias,
33                    hostname: hostname.clone(),
34                    user: user.take(),
35                    port,
36                    identity_file: identity_file.take(),
37                    tags: vec![],
38                    notes: None,
39                    tunnels: vec![],
40                    commands: vec![],
41                });
42            }
43
44            let alias = rest.trim().to_string();
45            current_alias = Some(alias);
46            hostname = String::new();
47            user = None;
48            port = 22;
49            identity_file = None;
50        } else if current_alias.is_some() {
51            let (key, value) = match trimmed.split_once(char::is_whitespace) {
52                Some((k, v)) => (k.to_lowercase(), v.trim().to_string()),
53                None => continue,
54            };
55
56            match key.as_str() {
57                "hostname" => hostname = value,
58                "user" => user = Some(value),
59                "port" => port = value.parse().unwrap_or(22),
60                "identityfile" => identity_file = Some(PathBuf::from(value)),
61                _ => {}
62            }
63        }
64    }
65
66    if let Some(alias) = current_alias
67        && !hostname.is_empty()
68        && alias != "*"
69    {
70        hosts.push(Host {
71            alias,
72            hostname,
73            user,
74            port,
75            identity_file,
76            tags: vec![],
77            notes: None,
78            tunnels: vec![],
79            commands: vec![],
80        });
81    }
82
83    hosts
84}
85
86// ---------------------------------------------------------------------------
87// SSH command paste import
88// ---------------------------------------------------------------------------
89
90#[derive(Debug, Clone, PartialEq)]
91pub struct ParsedTunnel {
92    pub local_port: u16,
93    pub remote_host: String,
94    pub remote_port: u16,
95}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct ParsedHost {
99    pub hostname: String,
100    pub user: Option<String>,
101    pub port: u16,
102    pub tunnels: Vec<ParsedTunnel>,
103}
104
105/// Parse raw text containing ssh commands (one per line) and group tunnels by host.
106/// Handles formats like:
107///   ssh -l ivan -nNTL 8881:localhost:8881 ash.onomatics.com -p 10010
108///   ssh -L 5432:localhost:5432 -N user@host
109pub fn parse_ssh_commands(text: &str) -> Vec<ParsedHost> {
110    let mut host_map: HashMap<(String, u16), ParsedHost> = HashMap::new();
111
112    for line in text.lines() {
113        let trimmed = line.trim();
114        if trimmed.is_empty() || !trimmed.contains("ssh ") && !trimmed.starts_with("ssh ") {
115            continue;
116        }
117
118        // Extract just the ssh command part (skip any prefix like echo, etc.)
119        let ssh_part = if let Some(idx) = trimmed.find("ssh ") {
120            &trimmed[idx..]
121        } else {
122            continue;
123        };
124
125        if let Some(parsed) = parse_single_ssh_command(ssh_part) {
126            let key = (parsed.hostname.clone(), parsed.port);
127            let entry = host_map.entry(key).or_insert_with(|| ParsedHost {
128                hostname: parsed.hostname.clone(),
129                user: parsed.user.clone(),
130                port: parsed.port,
131                tunnels: vec![],
132            });
133            entry.tunnels.extend(parsed.tunnels);
134        }
135    }
136
137    let mut hosts: Vec<ParsedHost> = host_map.into_values().collect();
138    hosts.sort_by(|a, b| a.hostname.cmp(&b.hostname));
139    hosts
140}
141
142#[derive(Debug)]
143struct ParsedCommand {
144    hostname: String,
145    user: Option<String>,
146    port: u16,
147    tunnels: Vec<ParsedTunnel>,
148}
149
150fn parse_single_ssh_command(cmd: &str) -> Option<ParsedCommand> {
151    let tokens: Vec<&str> = cmd.split_whitespace().collect();
152    if tokens.is_empty() || tokens[0] != "ssh" {
153        return None;
154    }
155
156    let mut user: Option<String> = None;
157    let mut port: u16 = 22;
158    let mut tunnels: Vec<ParsedTunnel> = Vec::new();
159    let mut hostname: Option<String> = None;
160    let mut i = 1;
161
162    while i < tokens.len() {
163        let tok = tokens[i];
164
165        if tok == "-l" {
166            i += 1;
167            if i < tokens.len() {
168                user = Some(tokens[i].to_string());
169            }
170        } else if tok == "-p" {
171            i += 1;
172            if i < tokens.len() {
173                port = tokens[i].parse().unwrap_or(22);
174            }
175        } else if tok == "-L" {
176            i += 1;
177            if i < tokens.len()
178                && let Some(t) = parse_tunnel_spec(tokens[i])
179            {
180                tunnels.push(t);
181            }
182        } else if tok.starts_with('-') {
183            // Combined flags like -nNTL — check if L is in there
184            if let Some(after_l) = tok.strip_suffix('L').or_else(|| {
185                // L might be followed by nothing (next token is the spec)
186                if tok.contains('L') {
187                    // -nNTL or -NL etc — L is at the end taking next arg
188                    let l_pos = tok.rfind('L')?;
189                    if l_pos == tok.len() - 1 {
190                        Some(&tok[..l_pos])
191                    } else {
192                        None
193                    }
194                } else {
195                    None
196                }
197            }) {
198                let _ = after_l;
199                i += 1;
200                if i < tokens.len()
201                    && let Some(t) = parse_tunnel_spec(tokens[i])
202                {
203                    tunnels.push(t);
204                }
205            }
206            // Other flags we don't care about (-n, -N, -T, -f, etc.)
207        } else if tok.contains('@') {
208            // user@host format
209            let parts: Vec<&str> = tok.splitn(2, '@').collect();
210            if parts.len() == 2 {
211                user = Some(parts[0].to_string());
212                hostname = Some(parts[1].to_string());
213            }
214        } else if tok.ends_with('&') {
215            // trailing & from backgrounding — skip
216        } else {
217            // Positional argument = hostname
218            hostname = Some(tok.to_string());
219        }
220
221        i += 1;
222    }
223
224    let hostname = hostname?;
225    if tunnels.is_empty() {
226        return None;
227    }
228
229    Some(ParsedCommand {
230        hostname,
231        user,
232        port,
233        tunnels,
234    })
235}
236
237fn parse_tunnel_spec(spec: &str) -> Option<ParsedTunnel> {
238    // Format: local_port:remote_host:remote_port
239    let parts: Vec<&str> = spec.splitn(3, ':').collect();
240    if parts.len() == 3 {
241        let local_port = parts[0].parse().ok()?;
242        let remote_host = parts[1].to_string();
243        let remote_port = parts[2].parse().ok()?;
244        Some(ParsedTunnel {
245            local_port,
246            remote_host,
247            remote_port,
248        })
249    } else {
250        None
251    }
252}
253
254/// Generate a short alias from a hostname (e.g. "ash.onomatics.com" → "ash")
255pub fn alias_from_hostname(hostname: &str) -> String {
256    hostname
257        .split('.')
258        .next()
259        .unwrap_or(hostname)
260        .to_string()
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use std::io::Write;
267    use tempfile::NamedTempFile;
268
269    #[test]
270    fn test_parse_basic_hosts() {
271        let mut f = NamedTempFile::new().unwrap();
272        write!(
273            f,
274            r#"
275Host prod-api
276    HostName 10.0.1.50
277    User deploy
278    Port 2222
279    IdentityFile ~/.ssh/id_ed25519
280
281Host staging
282    HostName staging.example.com
283    User admin
284"#
285        )
286        .unwrap();
287
288        let hosts = parse_ssh_config(f.path());
289        assert_eq!(hosts.len(), 2);
290
291        assert_eq!(hosts[0].alias, "prod-api");
292        assert_eq!(hosts[0].hostname, "10.0.1.50");
293        assert_eq!(hosts[0].user, Some("deploy".to_string()));
294        assert_eq!(hosts[0].port, 2222);
295        assert_eq!(
296            hosts[0].identity_file,
297            Some(PathBuf::from("~/.ssh/id_ed25519"))
298        );
299
300        assert_eq!(hosts[1].alias, "staging");
301        assert_eq!(hosts[1].hostname, "staging.example.com");
302        assert_eq!(hosts[1].user, Some("admin".to_string()));
303        assert_eq!(hosts[1].port, 22);
304    }
305
306    #[test]
307    fn test_skips_wildcard_and_no_hostname() {
308        let mut f = NamedTempFile::new().unwrap();
309        write!(
310            f,
311            r#"
312Host *
313    ServerAliveInterval 60
314
315Host no-hostname
316    User test
317
318Host valid
319    HostName 1.2.3.4
320"#
321        )
322        .unwrap();
323
324        let hosts = parse_ssh_config(f.path());
325        assert_eq!(hosts.len(), 1);
326        assert_eq!(hosts[0].alias, "valid");
327    }
328
329    #[test]
330    fn test_nonexistent_file_returns_empty() {
331        let hosts = parse_ssh_config(Path::new("/tmp/nonexistent-ssh-config-12345"));
332        assert!(hosts.is_empty());
333    }
334
335    #[test]
336    fn test_parse_ssh_commands_basic() {
337        let input = r#"
338ssh -l ivan -nNTL 8881:localhost:8881 ash.onomatics.com -p 10010 &
339ssh -l ivan -nNTL 8700:localhost:8700 ash.onomatics.com -p 10010 &
340ssh -l ivan -nNTL 7213:localhost:7213 mediaservice-dev.onomatics.com -p 10010 &
341"#;
342        let hosts = parse_ssh_commands(input);
343        assert_eq!(hosts.len(), 2);
344
345        let ash = hosts.iter().find(|h| h.hostname == "ash.onomatics.com").unwrap();
346        assert_eq!(ash.port, 10010);
347        assert_eq!(ash.user, Some("ivan".to_string()));
348        assert_eq!(ash.tunnels.len(), 2);
349        assert!(ash.tunnels.iter().any(|t| t.local_port == 8881));
350        assert!(ash.tunnels.iter().any(|t| t.local_port == 8700));
351
352        let media = hosts.iter().find(|h| h.hostname == "mediaservice-dev.onomatics.com").unwrap();
353        assert_eq!(media.tunnels.len(), 1);
354        assert_eq!(media.tunnels[0].local_port, 7213);
355    }
356
357    #[test]
358    fn test_parse_ssh_commands_user_at_host() {
359        let input = "ssh -NL 5432:localhost:5432 deploy@db.example.com";
360        let hosts = parse_ssh_commands(input);
361        assert_eq!(hosts.len(), 1);
362        assert_eq!(hosts[0].hostname, "db.example.com");
363        assert_eq!(hosts[0].user, Some("deploy".to_string()));
364        assert_eq!(hosts[0].tunnels[0].local_port, 5432);
365    }
366
367    #[test]
368    fn test_parse_ssh_commands_skips_non_tunnel() {
369        let input = r#"
370echo "Starting tunnels"
371ssh user@host.com
372ssh -l ivan -nNTL 8881:localhost:8881 ash.com -p 10010
373echo "done"
374"#;
375        let hosts = parse_ssh_commands(input);
376        assert_eq!(hosts.len(), 1);
377        assert_eq!(hosts[0].hostname, "ash.com");
378    }
379
380    #[test]
381    fn test_alias_from_hostname() {
382        assert_eq!(alias_from_hostname("ash.onomatics.com"), "ash");
383        assert_eq!(alias_from_hostname("10.0.1.50"), "10");
384        assert_eq!(alias_from_hostname("myhost"), "myhost");
385    }
386}