Skip to main content

shy/
ssh_config.rs

1use {
2    indexmap::IndexMap,
3    std::{env, fs, io},
4};
5
6pub type HostMap = IndexMap<String, String>;
7
8/// For now just load the hostnames and their labels.
9pub fn load_ssh_config(path: &str) -> io::Result<HostMap> {
10    parse_ssh_config(&fs::read_to_string(
11        path.replace('~', &env::var("HOME").expect("$HOME must be set")),
12    )?)
13}
14
15/// Parse .ssh/config to a (sorted) map.
16pub fn parse_ssh_config<S: AsRef<str>>(config: S) -> io::Result<HostMap> {
17    let config = config.as_ref();
18    let mut map = HostMap::new();
19
20    let mut token = String::new(); // the token we're parsing
21    let mut line = vec![]; // current line
22    let mut skip_line = false; // skip until EOL for comments
23    let mut stanza = String::new(); // ssh config is broken into stanzas
24    let mut key = true; // parsing the key or the value?
25
26    for c in config.chars() {
27        if skip_line {
28            if c == '\n' {
29                skip_line = false;
30            }
31            continue;
32        }
33
34        if c == '#' {
35            // skip comments
36            skip_line = true;
37            if !token.is_empty() {
38                line.push(token);
39                token = String::new();
40            }
41        } else if key && (c == ' ' || c == '=') {
42            // "key = value" OR "key value" separator
43            if !token.is_empty() {
44                line.push(token);
45                token = String::new();
46                key = false;
47            }
48        } else if c == '\n' {
49            if !token.is_empty() {
50                line.push(token);
51                token = String::new();
52                key = true;
53            }
54
55            if line.is_empty() {
56                continue;
57            } else if line.len() != 2 {
58                return Err(io::Error::new(
59                    io::ErrorKind::Other,
60                    format!("can't parse line: {:?}", line),
61                ));
62            } else {
63                match line[0].to_lowercase().as_ref() {
64                    "host" => {
65                        let parsed = &line[1];
66                        // skip any Host patterns
67                        if parsed.contains('*')
68                            || parsed.contains('!')
69                            || parsed.contains('?')
70                            || parsed.contains(',')
71                            || parsed.contains(' ')
72                        {
73                            stanza.clear();
74                        } else {
75                            stanza = parsed.clone();
76                            // by default we assume host patterns are
77                            // actual hostnames
78                            map.insert(stanza.clone(), stanza.clone());
79                        }
80                    }
81                    "hostname" => {
82                        if !stanza.is_empty() {
83                            map.insert(stanza.clone(), line[1].clone());
84                            stanza.clear();
85                        }
86                    }
87                    _ => {}
88                }
89                line.clear();
90            }
91        } else if (c == ' ' || c == '=') && token.is_empty() {
92            // skip = and whitespace at start of value, key = value format
93            continue;
94        } else {
95            // regular char
96            token.push(c);
97        }
98    }
99
100    Ok(map)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_config() {
109        let config = load_ssh_config("./tests/test_config").expect("failed to parse config");
110        assert_eq!(11, config.len());
111
112        assert_eq!(
113            config.keys().cloned().collect::<Vec<_>>(),
114            vec![
115                "homework-server",
116                "nixcraft",
117                "docker1",
118                "nas01",
119                "docker2",
120                "docker3",
121                "devserver",
122                "ec2-some-long-name.amazon.probably.com",
123                "ec2-some-long-namer.amazon.probably.com",
124                "torrentz-server",
125                "midi-files.com",
126            ]
127        );
128        assert_eq!("torrentz-r-us.com", config.get("torrentz-server").unwrap());
129        assert_eq!("docker3.mycloud.net", config.get("docker3").unwrap());
130        assert_eq!("192.168.1.100", config.get("nas01").unwrap());
131        assert_eq!("midi-files.com", config.get("midi-files.com").unwrap());
132    }
133}