1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2
3use crate::clone::ssh::drop_scp_scheme;
4use crate::errors::SshHostError;
5use crate::local_address::{local_address, LocalAddress};
6
7#[derive(Debug, Clone, Copy, Default)]
9pub struct AllowedLocal {
10 pub localhost: bool,
11 pub local_network: bool,
12}
13
14enum Host<'a> {
15 Ip(IpAddr),
16 Name(&'a str),
17}
18
19pub async fn validate_ssh_host(url: &str, allowed: AllowedLocal) -> Result<(), SshHostError> {
21 let host = stored_host(url).ok_or(SshHostError::Malformed)?;
22 let (name, ips) = match host {
23 Host::Ip(ip) => (ip.to_string(), vec![ip]),
24 Host::Name(name) => (name.to_owned(), resolve(name).await?),
25 };
26 if ips.is_empty() {
27 return Err(SshHostError::NoAddresses { host: name });
28 }
29 ips.into_iter()
30 .filter_map(local_address)
31 .find(|local| !allowed.lets_through(*local))
32 .map_or(Ok(()), |reason| {
33 Err(SshHostError::Local { host: name, reason })
34 })
35}
36
37impl AllowedLocal {
38 const fn lets_through(self, local: LocalAddress) -> bool {
39 match local {
40 LocalAddress::Loopback => self.localhost,
41 LocalAddress::LocalNetwork => self.local_network,
42 }
43 }
44}
45
46async fn resolve(name: &str) -> Result<Vec<IpAddr>, SshHostError> {
47 let addresses = tokio::net::lookup_host((name, 22))
48 .await
49 .map_err(|source| SshHostError::Unresolved {
50 host: name.to_owned(),
51 source,
52 })?;
53 Ok(addresses.map(|address| address.ip()).collect())
54}
55
56fn stored_host(url: &str) -> Option<Host<'_>> {
59 let url = drop_scp_scheme(url);
60 let (scp, rest) = url
61 .strip_prefix("ssh://")
62 .map_or((true, url), |rest| (false, rest));
63 let (user, rest) = rest.split_once('@')?;
64 if !is_user(user) || rest.contains('@') {
65 return None;
66 }
67 let (host, rest) = split_host(rest)?;
68 let path_follows = if scp {
69 rest.starts_with(':')
70 } else {
71 drop_port(rest).starts_with('/')
72 };
73 path_follows.then_some(host)
74}
75
76fn is_user(user: &str) -> bool {
77 let mut chars = user.chars();
78 chars
79 .next()
80 .is_some_and(|first| first.is_ascii_alphabetic())
81 && !chars.as_str().is_empty()
82 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
83}
84
85fn split_host(rest: &str) -> Option<(Host<'_>, &str)> {
86 if let Some(bracketed) = rest.strip_prefix('[') {
87 let (ip, after) = bracketed.split_once(']')?;
88 return Some((Host::Ip(IpAddr::V6(ip.parse::<Ipv6Addr>().ok()?)), after));
89 }
90 let end = rest.find([':', '/']).unwrap_or(rest.len());
91 let (host, rest) = rest.split_at(end);
92 let host = match host.parse::<Ipv4Addr>() {
93 Ok(ip) => Host::Ip(IpAddr::V4(ip)),
94 Err(_) => Host::Name(is_name(host).then_some(host)?),
95 };
96 Some((host, rest))
97}
98
99fn drop_port(rest: &str) -> &str {
100 rest.strip_prefix(':').map_or(rest, |port| {
101 port.trim_start_matches(|c: char| c.is_ascii_digit())
102 })
103}
104
105fn is_name(host: &str) -> bool {
107 let name = host.strip_suffix('.').unwrap_or(host);
108 name.len() <= 253
109 && name.split('.').all(|label| {
110 (1..=63).contains(&label.len())
111 && label
114 .chars()
115 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
116 })
117}
118
119#[cfg(test)]
120mod tests {
121 use super::{validate_ssh_host, AllowedLocal};
122 use crate::errors::SshHostError;
123 use crate::local_address::LocalAddress;
124
125 async fn check(url: &str) -> Result<(), SshHostError> {
126 validate_ssh_host(url, AllowedLocal::default()).await
127 }
128
129 #[tokio::test]
130 async fn each_form_integrates_stores_is_checked_on_its_host() {
131 for url in [
132 "ssh://git@10.0.0.1/org/repo",
133 "ssh://git@10.0.0.1:2222/org/repo",
134 "ssh://git@10.0.0.1:v3/org/project/repo",
135 "ssh://git@10.0.0.1:org/repo",
136 "git@10.0.0.1:org/repo.git",
137 "ssh://git@[fc00::1]:22/org/repo",
138 "git@[fc00::1]:org/repo.git",
139 ] {
140 assert!(
141 matches!(check(url).await, Err(SshHostError::Local { .. })),
142 "{url}"
143 );
144 }
145 }
146
147 #[tokio::test]
148 async fn a_url_outside_the_stored_forms_is_malformed() {
149 for url in [
150 "ssh:///org/repo",
151 "git@10.0.0.1:22@8.8.8.8:22/org/repo",
152 "ssh://git@8.8.8.8:22@8.8.8.8:22/org/repo",
153 "ssh://8.8.8.8/org/repo",
154 "8.8.8.8:org/repo",
155 "g@8.8.8.8:org/repo",
156 "1git@8.8.8.8:org/repo",
157 "git@8.8.8.8/org/repo",
158 "ssh://git@8.8.8.8",
159 "git@[::1:org/repo",
160 "git@[not-an-ip]:org/repo",
161 "git@exa mple.com:org/repo",
162 "ssh://git@%31%30.0.0.1/org/repo",
163 "ssh://git@10.0.0.1%2f.8.8.8.8.nip.io/org/repo",
164 "ssh://git@10.0.0.1%3a6379/org/repo",
165 "https://git@8.8.8.8/org/repo",
166 ] {
167 assert!(
168 matches!(check(url).await, Err(SshHostError::Malformed)),
169 "{url}"
170 );
171 }
172 }
173
174 #[tokio::test]
175 async fn a_host_past_the_dns_limits_is_malformed() {
176 let long_label = format!("git@{}.com:org/repo", "a".repeat(64));
177 let long_name = format!("git@{}abcd:org/repo", "a.".repeat(125));
178 let empty_label = "git@example..com:org/repo".to_owned();
179 for url in [long_label, long_name, empty_label] {
180 assert!(
181 matches!(check(&url).await, Err(SshHostError::Malformed)),
182 "{url}"
183 );
184 }
185 }
186
187 #[tokio::test]
188 async fn a_public_address_passes() {
189 check("git@8.8.8.8:org/repo.git").await.unwrap();
190 check("git@[2606:4700::1111]:org/repo.git").await.unwrap();
191 }
192
193 #[tokio::test]
194 async fn a_name_that_does_not_resolve_is_unresolved() {
195 let result = check("git@does-not-exist.invalid:org/repo.git").await;
196
197 assert!(matches!(result, Err(SshHostError::Unresolved { .. })));
198 }
199
200 #[tokio::test]
201 async fn a_name_is_checked_on_the_addresses_it_resolves_to() {
202 let result = check("git@localhost:org/repo.git").await;
203
204 assert!(matches!(
205 result,
206 Err(SshHostError::Local {
207 reason: LocalAddress::Loopback,
208 ..
209 })
210 ));
211 }
212
213 #[tokio::test]
214 async fn allowing_localhost_lets_loopback_through_and_nothing_else() {
215 let allowed = AllowedLocal {
216 localhost: true,
217 local_network: false,
218 };
219
220 validate_ssh_host("git@127.0.0.1:org/repo", allowed)
221 .await
222 .unwrap();
223 assert!(matches!(
224 validate_ssh_host("git@10.0.0.1:org/repo", allowed).await,
225 Err(SshHostError::Local {
226 reason: LocalAddress::LocalNetwork,
227 ..
228 })
229 ));
230 }
231
232 #[tokio::test]
233 async fn allowing_the_local_network_lets_it_through_and_not_loopback() {
234 let allowed = AllowedLocal {
235 localhost: false,
236 local_network: true,
237 };
238
239 validate_ssh_host("git@10.0.0.1:org/repo", allowed)
240 .await
241 .unwrap();
242 assert!(matches!(
243 validate_ssh_host("git@127.0.0.1:org/repo", allowed).await,
244 Err(SshHostError::Local {
245 reason: LocalAddress::Loopback,
246 ..
247 })
248 ));
249 }
250}