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}