ockam_transport_core/
hostname_port.rs

1use crate::parse_socket_addr;
2use core::fmt::{Display, Formatter};
3use core::net::IpAddr;
4use core::net::Ipv4Addr;
5use core::net::SocketAddr;
6use core::str::FromStr;
7use minicbor::{CborLen, Decode, Encode};
8use ockam_core::compat::format;
9use ockam_core::compat::string::{String, ToString};
10use ockam_core::errcode::{Kind, Origin};
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12#[cfg(feature = "std")]
13use url::Url;
14
15/// [`HostnamePort`]'s static counterpart usable for const values.
16pub struct StaticHostnamePort {
17    hostname: &'static str,
18    port: u16,
19}
20
21impl StaticHostnamePort {
22    pub const fn new(hostname: &'static str, port: u16) -> Self {
23        Self { hostname, port }
24    }
25
26    pub const fn localhost(port: u16) -> Self {
27        Self {
28            hostname: "127.0.0.1",
29            port,
30        }
31    }
32}
33
34impl TryFrom<StaticHostnamePort> for HostnamePort {
35    type Error = ockam_core::Error;
36
37    fn try_from(value: StaticHostnamePort) -> ockam_core::Result<Self> {
38        HostnamePort::new(value.hostname, value.port)
39    }
40}
41
42/// Hostname and port
43#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, CborLen)]
44#[rustfmt::skip]
45pub struct HostnamePort {
46    #[n(0)] pub hostname: String,
47    #[n(1)] pub port: u16,
48}
49
50impl HostnamePort {
51    /// Create a new HostnamePort
52    pub fn new(hostname: impl Into<String>, port: u16) -> ockam_core::Result<HostnamePort> {
53        let _self = Self {
54            hostname: hostname.into(),
55            port,
56        };
57        Self::validate(&_self.to_string())
58    }
59
60    /// Return the hostname
61    pub fn hostname(&self) -> String {
62        self.hostname.clone()
63    }
64
65    /// Return the port
66    pub fn port(&self) -> u16 {
67        self.port
68    }
69
70    #[cfg(feature = "std")]
71    pub fn into_url(self, scheme: &str) -> ockam_core::Result<Url> {
72        Url::parse(&format!("{}://{}:{}", scheme, self.hostname, self.port))
73            .map_err(|_| ockam_core::Error::new(Origin::Api, Kind::Serialization, "invalid url"))
74    }
75
76    fn validate(hostname_port: &str) -> ockam_core::Result<Self> {
77        // Check if the input is an IP address
78        if let Ok(socket) = parse_socket_addr(hostname_port) {
79            return Ok(HostnamePort::from(socket));
80        }
81
82        // Split the input into hostname and port
83        let (hostname, port_str) = match hostname_port.split_once(':') {
84            None => {
85                return Err(ockam_core::Error::new(
86                    Origin::Core,
87                    Kind::Parse,
88                    "Invalid format. Expected 'hostname:port'".to_string(),
89                ))
90            }
91            Some((hostname, port_str)) => (hostname, port_str),
92        };
93
94        // Validate port
95        let port = port_str.parse::<u16>().map_err(|_| {
96            ockam_core::Error::new(
97                Origin::Core,
98                Kind::Parse,
99                format!("Invalid port number {port_str}"),
100            )
101        })?;
102
103        // Ensure the hostname is a valid ASCII string
104        if !hostname.is_ascii() {
105            return Err(ockam_core::Error::new(
106                Origin::Core,
107                Kind::Parse,
108                format!("Hostname must be ascii: {hostname_port}"),
109            ));
110        }
111
112        // Validate hostname
113        if hostname.is_empty() {
114            return Err(ockam_core::Error::new(
115                Origin::Core,
116                Kind::Parse,
117                format!("Hostname cannot be empty {hostname}"),
118            ));
119        }
120
121        // The total length of the hostname should not exceed 253 characters
122        if hostname.len() > 253 {
123            return Err(ockam_core::Error::new(
124                Origin::Api,
125                Kind::Serialization,
126                format!("Hostname too long {hostname}"),
127            ));
128        }
129
130        // Hostname should not start or end with a hyphen or dot
131        if hostname.starts_with('-')
132            || hostname.ends_with('-')
133            || hostname.starts_with('.')
134            || hostname.ends_with('.')
135        {
136            return Err(ockam_core::Error::new(
137                Origin::Core,
138                Kind::Parse,
139                format!("Hostname cannot start or end with a hyphen or dot {hostname}"),
140            ));
141        }
142
143        // Check segments of the hostname
144        for segment in hostname.split('.') {
145            // Segment can't be empty (i.e. two dots in a row)
146            if segment.is_empty() {
147                return Err(ockam_core::Error::new(
148                    Origin::Core,
149                    Kind::Parse,
150                    format!("Hostname segment cannot be empty {hostname}"),
151                ));
152            }
153
154            // Hostname segments (between dots) should be between 1 and 63 characters long
155            if segment.len() > 63 {
156                return Err(ockam_core::Error::new(
157                    Origin::Core,
158                    Kind::Parse,
159                    format!("Hostname segment too long {hostname}"),
160                ));
161            }
162            // Hostname can contain alphanumeric characters, hyphens (-), dots (.), and underscores (_)
163            if !segment
164                .chars()
165                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
166            {
167                return Err(ockam_core::Error::new(
168                    Origin::Core,
169                    Kind::Parse,
170                    format!("Hostname contains invalid characters {hostname}"),
171                ));
172            }
173        }
174
175        Ok(Self {
176            hostname: hostname.to_string(),
177            port,
178        })
179    }
180
181    pub fn localhost(port: u16) -> Self {
182        Self {
183            hostname: Ipv4Addr::LOCALHOST.to_string(),
184            port,
185        }
186    }
187}
188
189impl From<SocketAddr> for HostnamePort {
190    fn from(socket_addr: SocketAddr) -> Self {
191        let ip = match socket_addr.ip() {
192            IpAddr::V4(ip) => ip.to_string(),
193            IpAddr::V6(ip) => format!("[{ip}]"),
194        };
195        Self {
196            hostname: ip,
197            port: socket_addr.port(),
198        }
199    }
200}
201
202impl Serialize for HostnamePort {
203    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
204    where
205        S: Serializer,
206    {
207        serializer.serialize_str(&self.to_string())
208    }
209}
210
211impl<'de> Deserialize<'de> for HostnamePort {
212    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213    where
214        D: Deserializer<'de>,
215    {
216        let s = String::deserialize(deserializer)?;
217        HostnamePort::from_str(&s).map_err(serde::de::Error::custom)
218    }
219}
220
221impl TryFrom<String> for HostnamePort {
222    type Error = ockam_core::Error;
223
224    fn try_from(value: String) -> ockam_core::Result<Self> {
225        FromStr::from_str(value.as_str())
226    }
227}
228
229impl TryFrom<&str> for HostnamePort {
230    type Error = ockam_core::Error;
231
232    fn try_from(value: &str) -> ockam_core::Result<Self> {
233        FromStr::from_str(value)
234    }
235}
236
237impl FromStr for HostnamePort {
238    type Err = ockam_core::Error;
239
240    /// Return a hostname and port when separated by a :
241    fn from_str(hostname_port: &str) -> ockam_core::Result<HostnamePort> {
242        // edge case: only the port is given
243        if let Ok(port) = hostname_port.parse::<u16>() {
244            return Ok(HostnamePort::localhost(port));
245        }
246
247        if let Some(port_str) = hostname_port.strip_prefix(':') {
248            if let Ok(port) = port_str.parse::<u16>() {
249                return Ok(HostnamePort::localhost(port));
250            }
251        }
252
253        if let Ok(socket) = parse_socket_addr(hostname_port) {
254            return Ok(HostnamePort::from(socket));
255        }
256
257        // We now know it's not an ip, let's validate if it can be a valid hostname
258        Self::validate(hostname_port)
259    }
260}
261
262impl Display for HostnamePort {
263    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
264        f.write_str(&format!("{}:{}", self.hostname, self.port))
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use core::str::FromStr;
272
273    #[test]
274    fn hostname_port_valid_inputs() -> ockam_core::Result<()> {
275        let valid_cases = vec![
276            ("localhost:80", HostnamePort::new("localhost", 80)?),
277            ("33domain:80", HostnamePort::new("33domain", 80)?),
278            ("127.0.0.1:80", HostnamePort::localhost(80)),
279            ("xn--74h.com:80", HostnamePort::new("xn--74h.com", 80)?),
280            (
281                "sub.xn_74h.com:80",
282                HostnamePort::new("sub.xn_74h.com", 80)?,
283            ),
284            (":80", HostnamePort::localhost(80)),
285            ("80", HostnamePort::localhost(80)),
286            (
287                "[2001:db8:85a3::8a2e:370:7334]:8080",
288                HostnamePort::new("[2001:db8:85a3::8a2e:370:7334]", 8080)?,
289            ),
290            ("[::1]:8080", HostnamePort::new("[::1]", 8080)?),
291            (
292                "[2001:db8:85a3::8a2e:370:7334]:8080",
293                HostnamePort::new("[2001:db8:85a3::8a2e:370:7334]", 8080)?,
294            ),
295        ];
296        for (input, expected) in valid_cases {
297            let actual = HostnamePort::from_str(input).ok().unwrap();
298            assert_eq!(actual, expected);
299        }
300
301        let socket_address_cases = vec![
302            (
303                SocketAddr::from_str("127.0.0.1:8080").unwrap(),
304                HostnamePort::localhost(8080),
305            ),
306            (
307                SocketAddr::from_str("[2001:db8:85a3::8a2e:370:7334]:8080").unwrap(),
308                HostnamePort::new("[2001:db8:85a3::8a2e:370:7334]", 8080)?,
309            ),
310            (
311                SocketAddr::from_str("[::1]:8080").unwrap(),
312                HostnamePort::new("[::1]", 8080)?,
313            ),
314        ];
315        for (input, expected) in socket_address_cases {
316            let actual = HostnamePort::from(input);
317            assert_eq!(actual, expected);
318        }
319
320        Ok(())
321    }
322
323    #[test]
324    fn hostname_port_invalid_inputs() {
325        let cases = [
326            "invalid",
327            "localhost:80:80",
328            "192,166,0.1:9999",
329            "-hostname-with-leading-hyphen:80",
330            "hostname-with-trailing-hyphen-:80",
331            ".hostname-with-leading-dot:80",
332            "hostname-with-trailing-dot.:80",
333            "hostname..with..multiple..dots:80",
334            "hostname_with_invalid_characters!@#:80",
335            "hostname_with_ space:80",
336            "hostname_with_backslash\\:80",
337            "hostname_with_slash/:80",
338            "hostname_with_colon::80",
339            "hostname_with_semicolon;:80",
340            "hostname_with_quote\":80",
341            "hostname_with_single_quote':80",
342            "hostname_with_question_mark?:80",
343            "hostname_with_asterisk*:80",
344            "hostname_with_ampersand&:80",
345            "hostname_with_percent%:80",
346            "hostname_with_dollar$:80",
347            "hostname_with_hash#:80",
348            "hostname_with_at@:80",
349            "hostname_with_exclamation!:80",
350            "hostname_with_tilde~:80",
351            "hostname_with_caret^:80",
352            "hostname_with_open_bracket[:80",
353            "hostname_with_close_bracket]:80",
354            "hostname_with_open_brace{:80",
355            "hostname_with_close_brace}:80",
356            "hostname_with_open_parenthesis(:80",
357            "hostname_with_close_parenthesis):80",
358            "hostname_with_plus+:80",
359            "hostname_with_equal=:80",
360            "hostname_with_comma,:80",
361            "hostname_with_less_than<:80",
362            "hostname_with_greater_than>:80",
363        ];
364        for case in cases.iter() {
365            if HostnamePort::from_str(case).is_ok() {
366                panic!("HostnamePort should fail for '{case}'");
367            }
368        }
369    }
370}