Skip to main content

strop_workspace/addr/
endpoint.rs

1//! The validated connection identity: `ssh://[user@]host[:port]`. Private
2//! fields leave [`RemoteEndpoint::parse`] as the only constructor, so every
3//! value — including deserialized ones — passed the same admission and can
4//! never be option-shaped toward the `ssh` argv.
5
6use super::error::AddressError;
7use std::num::NonZeroU16;
8use std::str::FromStr;
9
10/// A validated `ssh://[user@]host[:port]` endpoint.
11///
12/// One endpoint names one pooled session. Aliases keep their spelling: this
13/// layer does not infer equivalence between OpenSSH configuration targets.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct RemoteEndpoint {
16    user: Option<String>,
17    /// Alias/hostname verbatim, or the canonical unbracketed IP form.
18    host: String,
19    port: Option<NonZeroU16>,
20}
21
22impl RemoteEndpoint {
23    /// Admit one endpoint-only location: no path is allowed. Pure: no
24    /// filesystem, no subprocess.
25    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    /// Split `ssh://[user@]host[:port]` off the front of `value`, returning
34    /// the endpoint and the untouched remainder (empty, or a path region).
35    pub(super) fn split_authority(value: &str) -> Result<(Self, &str), AddressError> {
36        parse_authority(value)
37    }
38
39    /// Hostname, alias, or the canonical unbracketed IPv4/IPv6 form.
40    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    /// The canonical authority spelling `ssh://[user@]host[:port]`.
53    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
88/// Serde carries the canonical validated URI — one string, re-parsed on
89/// the way in, so stored values can never bypass admission.
90impl 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    // Digits-and-dots is IPv4-shaped: admit it only as a real address,
159    // so `01.2.3.4` or `999.1.1.1` cannot reach getaddrinfo as an alias.
160    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    // OpenSSH config can interpolate %h/%r in ProxyCommand/Match exec. Keep
182    // shell metacharacters out even when an older ssh accepts them as argv.
183    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}