ssh_config.rs

  1use std::collections::BTreeSet;
  2
  3const FILTERED_GIT_PROVIDER_HOSTNAMES: &[&str] = &[
  4    "dev.azure.com",
  5    "bitbucket.org",
  6    "chromium.googlesource.com",
  7    "codeberg.org",
  8    "gitea.com",
  9    "gitee.com",
 10    "github.com",
 11    "gist.github.com",
 12    "gitlab.com",
 13    "sourcehut.org",
 14    "git.sr.ht",
 15];
 16
 17pub fn parse_ssh_config_hosts(config: &str) -> BTreeSet<String> {
 18    parse_host_blocks(config)
 19        .into_iter()
 20        .flat_map(HostBlock::non_git_provider_hosts)
 21        .collect()
 22}
 23
 24struct HostBlock {
 25    aliases: BTreeSet<String>,
 26    hostname: Option<String>,
 27}
 28
 29impl HostBlock {
 30    fn non_git_provider_hosts(self) -> impl Iterator<Item = String> {
 31        let hostname = self.hostname;
 32        let hostname_ref = hostname.as_deref().map(is_git_provider_domain);
 33        self.aliases
 34            .into_iter()
 35            .filter(move |alias| !hostname_ref.unwrap_or_else(|| is_git_provider_domain(alias)))
 36    }
 37}
 38
 39fn parse_host_blocks(config: &str) -> Vec<HostBlock> {
 40    let mut blocks = Vec::new();
 41    let mut aliases = BTreeSet::new();
 42    let mut hostname = None;
 43    let mut needs_continuation = false;
 44
 45    for line in config.lines() {
 46        let line = line.trim_start();
 47
 48        if needs_continuation {
 49            needs_continuation = line.trim_end().ends_with('\\');
 50            parse_hosts(line, &mut aliases);
 51            continue;
 52        }
 53
 54        let Some((keyword, value)) = split_keyword_and_value(line) else {
 55            continue;
 56        };
 57
 58        if keyword.eq_ignore_ascii_case("host") {
 59            if !aliases.is_empty() {
 60                blocks.push(HostBlock { aliases, hostname });
 61                aliases = BTreeSet::new();
 62                hostname = None;
 63            }
 64            parse_hosts(value, &mut aliases);
 65            needs_continuation = line.trim_end().ends_with('\\');
 66        } else if keyword.eq_ignore_ascii_case("hostname") {
 67            hostname = value.split_whitespace().next().map(ToOwned::to_owned);
 68        }
 69    }
 70
 71    if !aliases.is_empty() {
 72        blocks.push(HostBlock { aliases, hostname });
 73    }
 74
 75    blocks
 76}
 77
 78fn parse_hosts(line: &str, hosts: &mut BTreeSet<String>) {
 79    hosts.extend(
 80        line.split_whitespace()
 81            .map(|field| field.trim_end_matches('\\'))
 82            .filter(|field| !field.starts_with("!"))
 83            .filter(|field| !field.contains("*"))
 84            .filter(|field| *field != "\\")
 85            .filter(|field| !field.is_empty())
 86            .map(|field| field.to_owned()),
 87    );
 88}
 89
 90fn split_keyword_and_value(line: &str) -> Option<(&str, &str)> {
 91    let keyword_end = line.find(char::is_whitespace).unwrap_or(line.len());
 92    let keyword = &line[..keyword_end];
 93    if keyword.is_empty() {
 94        return None;
 95    }
 96
 97    let value = line[keyword_end..].trim_start();
 98    Some((keyword, value))
 99}
100
101fn is_git_provider_domain(host: &str) -> bool {
102    let host = host.to_ascii_lowercase();
103    FILTERED_GIT_PROVIDER_HOSTNAMES.contains(&host.as_str())
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use indoc::indoc;
110
111    #[test]
112    fn test_thank_you_bjorn3() {
113        let hosts = indoc! {"
114            Host *
115              AddKeysToAgent yes
116              UseKeychain yes
117              IdentityFile ~/.ssh/id_ed25519
118
119            Host whatever.*
120            User another
121
122            Host !not_this
123            User not_me
124
125            Host something
126              HostName whatever.tld
127
128            Host linux bsd host3
129              User bjorn
130
131            Host rpi
132              user rpi
133              hostname rpi.local
134
135            Host \\
136                   somehost \\
137                   anotherhost
138              Hostname 192.168.3.3
139        "};
140
141        let expected_hosts = BTreeSet::from_iter([
142            "something".to_owned(),
143            "linux".to_owned(),
144            "host3".to_owned(),
145            "bsd".to_owned(),
146            "rpi".to_owned(),
147            "somehost".to_owned(),
148            "anotherhost".to_owned(),
149        ]);
150
151        assert_eq!(expected_hosts, parse_ssh_config_hosts(hosts));
152    }
153
154    #[test]
155    fn filters_git_provider_domains_from_hostname() {
156        let hosts = indoc! {"
157            Host github-personal
158              HostName github.com
159
160            Host gitlab-work
161              HostName GITLAB.COM
162
163            Host local
164              HostName example.com
165        "};
166
167        assert_eq!(
168            BTreeSet::from_iter(["local".to_owned()]),
169            parse_ssh_config_hosts(hosts)
170        );
171    }
172
173    #[test]
174    fn falls_back_to_host_when_hostname_is_absent() {
175        let hosts = indoc! {"
176            Host github.com bitbucket.org keep-me
177              User git
178        "};
179
180        assert_eq!(
181            BTreeSet::from_iter(["keep-me".to_owned()]),
182            parse_ssh_config_hosts(hosts)
183        );
184    }
185
186    #[test]
187    fn does_not_fuzzy_match_host_aliases() {
188        let hosts = indoc! {"
189            Host GitHub GitLab Bitbucket GITHUB github
190              User git
191        "};
192
193        assert_eq!(
194            BTreeSet::from_iter([
195                "Bitbucket".to_owned(),
196                "GITHUB".to_owned(),
197                "GitHub".to_owned(),
198                "GitLab".to_owned(),
199                "github".to_owned(),
200            ]),
201            parse_ssh_config_hosts(hosts)
202        );
203    }
204
205    #[test]
206    fn uses_hostname_before_host_filtering() {
207        let hosts = indoc! {"
208            Host github.com keep-me
209              HostName example.com
210        "};
211
212        assert_eq!(
213            BTreeSet::from_iter(["github.com".to_owned(), "keep-me".to_owned()]),
214            parse_ssh_config_hosts(hosts)
215        );
216    }
217}