1use super::error::AddressError;
7use std::num::NonZeroU16;
8use std::str::FromStr;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct RemoteEndpoint {
16 user: Option<String>,
17 host: String,
19 port: Option<NonZeroU16>,
20}
21
22impl RemoteEndpoint {
23 pub fn parse(value: &str) -> Result<Self, AddressError> {
26 let (endpoint, rest) = parse_authority(value)?;
27 if !rest.is_empty() {
28 return Err(AddressError::PathOnEndpoint);
29 }
30 Ok(endpoint)
31 }
32
33 pub(super) fn split_authority(value: &str) -> Result<(Self, &str), AddressError> {
36 parse_authority(value)
37 }
38
39 pub fn host(&self) -> &str {
41 &self.host
42 }
43
44 pub fn user(&self) -> Option<&str> {
45 self.user.as_deref()
46 }
47
48 pub fn port(&self) -> Option<NonZeroU16> {
49 self.port
50 }
51
52 fn authority(&self) -> String {
54 use std::fmt::Write as _;
55 let mut out = String::with_capacity("ssh://".len() + self.host.len() + 8);
56 out.push_str("ssh://");
57 if let Some(user) = &self.user {
58 out.push_str(user);
59 out.push('@');
60 }
61 if self.host.contains(':') {
62 out.push('[');
63 out.push_str(&self.host);
64 out.push(']');
65 } else {
66 out.push_str(&self.host);
67 }
68 if let Some(port) = self.port {
69 let _ = write!(out, ":{port}");
70 }
71 out
72 }
73}
74
75impl FromStr for RemoteEndpoint {
76 type Err = AddressError;
77 fn from_str(value: &str) -> Result<Self, Self::Err> {
78 Self::parse(value)
79 }
80}
81
82impl std::fmt::Display for RemoteEndpoint {
83 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 formatter.write_str(&self.authority())
85 }
86}
87
88impl serde::Serialize for RemoteEndpoint {
91 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
92 serializer.serialize_str(&self.authority())
93 }
94}
95
96impl<'de> serde::Deserialize<'de> for RemoteEndpoint {
97 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
98 let text = String::deserialize(deserializer)?;
99 RemoteEndpoint::parse(&text).map_err(serde::de::Error::custom)
100 }
101}
102
103fn parse_authority(value: &str) -> Result<(RemoteEndpoint, &str), AddressError> {
104 let rest = value
105 .strip_prefix("ssh://")
106 .ok_or(AddressError::NotSshUri)?;
107 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
108 let (user, host_port) = split_user(&rest[..authority_end])?;
109 let (host, port) = split_host(host_port)?;
110 Ok((RemoteEndpoint { user, host, port }, &rest[authority_end..]))
111}
112
113fn split_user(authority: &str) -> Result<(Option<String>, &str), AddressError> {
114 let Some(at) = authority.find('@') else {
115 return Ok((None, authority));
116 };
117 let (user, host_port) = (&authority[..at], &authority[at + 1..]);
118 if host_port.contains('@') {
119 return Err(AddressError::InvalidAuthorityCharacter);
120 }
121 if user.is_empty() {
122 return Err(AddressError::EmptyUser);
123 }
124 if user.contains(':') {
125 return Err(AddressError::PasswordInUri);
126 }
127 check_endpoint_token(user)?;
128 Ok((Some(user.to_owned()), host_port))
129}
130
131fn split_host(host_port: &str) -> Result<(String, Option<NonZeroU16>), AddressError> {
132 if let Some(bracketed) = host_port.strip_prefix('[') {
133 let close = bracketed.find(']').ok_or(AddressError::UnclosedIpv6)?;
134 let inner = &bracketed[..close];
135 let after = &bracketed[close + 1..];
136 let port = if after.is_empty() {
137 None
138 } else if let Some(digits) = after.strip_prefix(':') {
139 Some(parse_port(digits)?)
140 } else {
141 return Err(AddressError::JunkAfterIpv6);
142 };
143 let address: std::net::Ipv6Addr = inner.parse().map_err(|_| AddressError::InvalidIpv6 {
144 literal: inner.to_owned(),
145 })?;
146 return Ok((address.to_string(), port));
147 }
148 if host_port.matches(':').count() > 1 {
149 return Err(AddressError::UnbracketedIpv6);
150 }
151 let (host, port) = match host_port.split_once(':') {
152 Some((host, digits)) => (host, Some(parse_port(digits)?)),
153 None => (host_port, None),
154 };
155 if host.is_empty() {
156 return Err(AddressError::EmptyHost);
157 }
158 if host.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
161 let address: std::net::Ipv4Addr = host.parse().map_err(|_| AddressError::InvalidIpv4 {
162 literal: host.to_owned(),
163 })?;
164 return Ok((address.to_string(), port));
165 }
166 check_endpoint_token(host)?;
167 Ok((host.to_owned(), port))
168}
169
170fn check_endpoint_token(token: &str) -> Result<(), AddressError> {
171 if token.starts_with('-') || token.contains('=') {
172 return Err(AddressError::OptionShapedEndpoint);
173 }
174 if !token.bytes().all(is_endpoint_byte) {
175 return Err(AddressError::InvalidAuthorityCharacter);
176 }
177 Ok(())
178}
179
180fn is_endpoint_byte(byte: u8) -> bool {
181 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'+')
184}
185
186fn parse_port(digits: &str) -> Result<NonZeroU16, AddressError> {
187 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
188 return Err(AddressError::InvalidPort);
189 }
190 match digits.parse::<u16>() {
191 Ok(value) => NonZeroU16::new(value).ok_or(AddressError::InvalidPort),
192 Err(_) => Err(AddressError::InvalidPort),
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use std::collections::HashSet;
200
201 fn ok(uri: &str) -> RemoteEndpoint {
202 RemoteEndpoint::parse(uri).unwrap_or_else(|error| panic!("expected parse: {uri}: {error}"))
203 }
204
205 fn refused(uri: &str) -> AddressError {
206 RemoteEndpoint::parse(uri).unwrap_err()
207 }
208
209 #[test]
210 fn alias_with_user_and_port() {
211 let endpoint = ok("ssh://dev@bbgithub:2222");
212 assert_eq!(endpoint.user(), Some("dev"));
213 assert_eq!(endpoint.host(), "bbgithub");
214 assert_eq!(endpoint.port(), NonZeroU16::new(2222));
215 assert_eq!(endpoint.to_string(), "ssh://dev@bbgithub:2222");
216 }
217
218 #[test]
219 fn a_path_is_refused_on_an_endpoint() {
220 assert!(matches!(
221 refused("ssh://host/var/log"),
222 AddressError::PathOnEndpoint
223 ));
224 }
225
226 #[test]
227 fn aliases_keep_their_spelling() {
228 assert_ne!(ok("ssh://Box"), ok("ssh://box"));
229 assert_eq!(ok("ssh://dev_01.a").host(), "dev_01.a");
230 }
231
232 #[test]
233 fn ipv6_brackets_canonicalize_and_roundtrip() {
234 let endpoint = ok("ssh://root@[2001:0db8:0000::0001]:22");
235 assert_eq!(endpoint.host(), "2001:db8::1");
236 assert_eq!(endpoint.to_string(), "ssh://root@[2001:db8::1]:22");
237 assert_eq!(endpoint, ok("ssh://root@[2001:0db8:0:0:0:0:0:1]:0022"));
238 let bare = ok("ssh://[::1]");
239 assert_eq!(bare.host(), "::1");
240 assert_eq!(bare.port(), None);
241 assert_eq!(bare.to_string(), "ssh://[::1]");
242 }
243
244 #[test]
245 fn ipv6_hostility() {
246 for (uri, expected) in [
247 ("ssh://fe80::1", AddressError::UnbracketedIpv6),
248 ("ssh://user@::1", AddressError::UnbracketedIpv6),
249 ("ssh://[::1", AddressError::UnclosedIpv6),
250 ("ssh://[::1]extra", AddressError::JunkAfterIpv6),
251 ] {
252 assert_eq!(refused(uri), expected, "{uri}");
253 }
254 assert!(matches!(
255 refused("ssh://[zz]"),
256 AddressError::InvalidIpv6 { .. }
257 ));
258 }
259
260 #[test]
261 fn ipv4_shapes_must_be_addresses() {
262 assert_eq!(ok("ssh://127.0.0.1").host(), "127.0.0.1");
263 for uri in [
264 "ssh://999.1.1.1",
265 "ssh://1.2.3.4.5",
266 "ssh://1.2.3",
267 "ssh://0",
268 ] {
269 assert!(
270 matches!(refused(uri), AddressError::InvalidIpv4 { .. }),
271 "{uri}"
272 );
273 }
274 }
275
276 #[test]
277 fn port_boundaries() {
278 assert_eq!(ok("ssh://h:022").port(), NonZeroU16::new(22));
279 for uri in [
280 "ssh://h:0",
281 "ssh://h:",
282 "ssh://h:65536",
283 "ssh://h:notaport",
284 "ssh://h:+2",
285 "ssh://h: 2",
286 ] {
287 assert!(matches!(refused(uri), AddressError::InvalidPort), "{uri}");
288 }
289 }
290
291 #[test]
292 fn hostile_authorities() {
293 assert!(matches!(
294 refused("ssh://user:secret@host"),
295 AddressError::PasswordInUri
296 ));
297 assert!(matches!(
298 refused("ssh://-oProxyCommand=evil@host"),
299 AddressError::OptionShapedEndpoint
300 ));
301 assert!(matches!(
302 refused("ssh://name=-x@host"),
303 AddressError::OptionShapedEndpoint
304 ));
305 assert!(matches!(
306 refused("ssh://-flag"),
307 AddressError::OptionShapedEndpoint
308 ));
309 assert!(matches!(refused("ssh://@host"), AddressError::EmptyUser));
310 assert!(matches!(refused("ssh://h@"), AddressError::EmptyHost));
311 assert!(matches!(refused("ssh://"), AddressError::EmptyHost));
312 assert!(matches!(
313 refused("ssh://a@b@c"),
314 AddressError::InvalidAuthorityCharacter
315 ));
316 assert!(matches!(
317 refused("ssh://ho%st"),
318 AddressError::InvalidAuthorityCharacter
319 ));
320 assert!(matches!(
321 refused("ssh://ho st"),
322 AddressError::InvalidAuthorityCharacter
323 ));
324 for uri in ["host", "scp://host", "", "SSH://host"] {
325 assert!(matches!(refused(uri), AddressError::NotSshUri), "{uri}");
326 }
327 }
328
329 #[test]
330 fn identity_and_hashing_follow_the_decoded_value() {
331 assert_eq!(ok("ssh://h:022"), ok("ssh://h:22"));
332 assert_eq!(ok("ssh://[::1]"), ok("ssh://[0:0:0:0:0:0:0:1]"));
333 let mut seen = HashSet::new();
334 seen.insert(ok("ssh://h:022"));
335 seen.insert(ok("ssh://h:22"));
336 seen.insert(ok("ssh://[::1]"));
337 seen.insert(ok("ssh://[0::1]"));
338 assert_eq!(seen.len(), 2);
339 }
340
341 #[test]
342 fn serde_roundtrips_the_canonical_uri() {
343 let endpoint = ok("ssh://dev@box:2222");
344 let json = serde_json::to_string(&endpoint).expect("serialize");
345 assert_eq!(json, "\"ssh://dev@box:2222\"");
346 assert_eq!(
347 serde_json::from_str::<RemoteEndpoint>(&json).expect("deserialize"),
348 endpoint
349 );
350 assert!(serde_json::from_str::<RemoteEndpoint>("\"ssh://h/x\"").is_err());
351 }
352}