commonware_utils/
hostname.rs1#[cfg(not(feature = "std"))]
4use alloc::{string::String, vec::Vec};
5use bytes::{Buf, BufMut};
6use commonware_codec::{
7 EncodeSize, Error as CodecError, RangeCfg, Read as CodecRead, Write as CodecWrite,
8};
9use thiserror::Error;
10
11pub const MAX_HOSTNAME_LEN: usize = 253;
16
17pub const MAX_HOSTNAME_LABEL_LEN: usize = 63;
19
20#[derive(Debug, Clone, PartialEq, Eq, Error)]
22pub enum Error {
23 #[error("hostname is empty")]
24 Empty,
25 #[error("hostname exceeds maximum length of {MAX_HOSTNAME_LEN} characters")]
26 TooLong,
27 #[error("hostname label exceeds maximum length of {MAX_HOSTNAME_LABEL_LEN} characters")]
28 LabelTooLong,
29 #[error("hostname contains empty label")]
30 EmptyLabel,
31 #[error("hostname contains invalid character")]
32 InvalidCharacter,
33 #[error("hostname label starts with hyphen")]
34 LabelStartsWithHyphen,
35 #[error("hostname label ends with hyphen")]
36 LabelEndsWithHyphen,
37 #[error("hostname contains invalid UTF-8")]
38 InvalidUtf8,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
50pub struct Hostname(String);
51
52impl Hostname {
53 pub fn new(hostname: impl Into<String>) -> Result<Self, Error> {
55 let hostname = hostname.into();
56 Self::validate(&hostname)?;
57 Ok(Self(hostname))
58 }
59
60 fn validate(hostname: &str) -> Result<(), Error> {
62 if hostname.is_empty() {
63 return Err(Error::Empty);
64 }
65
66 if hostname.len() > MAX_HOSTNAME_LEN {
67 return Err(Error::TooLong);
68 }
69
70 for label in hostname.split('.') {
71 Self::validate_label(label)?;
72 }
73
74 Ok(())
75 }
76
77 fn validate_label(label: &str) -> Result<(), Error> {
79 if label.is_empty() {
80 return Err(Error::EmptyLabel);
81 }
82
83 if label.len() > MAX_HOSTNAME_LABEL_LEN {
84 return Err(Error::LabelTooLong);
85 }
86
87 for c in label.chars() {
88 if !c.is_ascii_alphanumeric() && c != '-' {
89 return Err(Error::InvalidCharacter);
90 }
91 }
92
93 if label.starts_with('-') {
94 return Err(Error::LabelStartsWithHyphen);
95 }
96 if label.ends_with('-') {
97 return Err(Error::LabelEndsWithHyphen);
98 }
99
100 Ok(())
101 }
102
103 pub fn as_str(&self) -> &str {
105 &self.0
106 }
107
108 pub fn into_string(self) -> String {
110 self.0
111 }
112}
113
114impl AsRef<str> for Hostname {
115 fn as_ref(&self) -> &str {
116 &self.0
117 }
118}
119
120impl core::fmt::Display for Hostname {
121 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
122 write!(f, "{}", self.0)
123 }
124}
125
126impl TryFrom<String> for Hostname {
127 type Error = Error;
128
129 fn try_from(value: String) -> Result<Self, Self::Error> {
130 Self::new(value)
131 }
132}
133
134impl TryFrom<&str> for Hostname {
135 type Error = Error;
136
137 fn try_from(value: &str) -> Result<Self, Self::Error> {
138 Self::new(value)
139 }
140}
141
142impl CodecWrite for Hostname {
143 #[inline]
144 fn write(&self, buf: &mut impl BufMut) {
145 self.0.as_bytes().write(buf);
146 }
147}
148
149impl EncodeSize for Hostname {
150 #[inline]
151 fn encode_size(&self) -> usize {
152 self.0.as_bytes().encode_size()
153 }
154}
155
156impl CodecRead for Hostname {
157 type Cfg = ();
158
159 #[inline]
160 fn read_cfg(buf: &mut impl Buf, _: &()) -> Result<Self, CodecError> {
161 let bytes = Vec::<u8>::read_cfg(buf, &(RangeCfg::new(..=MAX_HOSTNAME_LEN), ()))?;
162 let hostname = String::from_utf8(bytes)
163 .map_err(|_| CodecError::Invalid("Hostname", "invalid UTF-8"))?;
164 Self::new(hostname).map_err(|_| CodecError::Invalid("Hostname", "invalid hostname"))
165 }
166}
167
168#[macro_export]
186macro_rules! hostname {
187 ($s:expr) => {
188 $crate::Hostname::new($s).expect("invalid hostname")
189 };
190}
191
192#[cfg(feature = "arbitrary")]
193impl arbitrary::Arbitrary<'_> for Hostname {
194 fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> {
195 let num_labels: u8 = u.int_in_range(1..=4)?;
196 let mut labels = Vec::with_capacity(num_labels as usize);
197
198 for _ in 0..num_labels {
199 let label_len: u8 = u.int_in_range(1..=10)?;
200 let label: String = (0..label_len)
201 .map(|i| {
202 if i == 0 || i == label_len - 1 {
203 u.choose(&['a', 'b', 'c', 'd', 'e', '1', '2', '3'])
204 } else {
205 u.choose(&['a', 'b', 'c', 'd', 'e', '1', '2', '3', '-'])
206 }
207 })
208 .collect::<Result<_, _>>()?;
209 labels.push(label);
210 }
211
212 let hostname = labels.join(".");
213 Ok(Self(hostname))
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_hostname_valid() {
223 assert!(Hostname::new("localhost").is_ok());
225 assert!(Hostname::new("example").is_ok());
226 assert!(Hostname::new("a").is_ok());
227
228 assert!(Hostname::new("example.com").is_ok());
230 assert!(Hostname::new("sub.example.com").is_ok());
231 assert!(Hostname::new("deep.sub.example.com").is_ok());
232
233 assert!(Hostname::new("my-host").is_ok());
235 assert!(Hostname::new("my-example-host.com").is_ok());
236 assert!(Hostname::new("a-b-c.d-e-f.com").is_ok());
237
238 assert!(Hostname::new("123").is_ok());
240 assert!(Hostname::new("123.456").is_ok());
241 assert!(Hostname::new("host1.example2.com").is_ok());
242 assert!(Hostname::new("1host.2example.3com").is_ok());
243
244 assert!(Hostname::new("Example.COM").is_ok());
246 assert!(Hostname::new("MyHost.Example.Com").is_ok());
247 }
248
249 #[test]
250 fn test_hostname_invalid_empty() {
251 assert!(matches!(Hostname::new("").unwrap_err(), Error::Empty));
252 }
253
254 #[test]
255 fn test_hostname_invalid_too_long() {
256 let long_label = "a".repeat(63);
259 let long_hostname = format!("{long_label}.{long_label}.{long_label}.{long_label}");
260 assert_eq!(long_hostname.len(), 255);
261 assert!(matches!(
262 Hostname::new(&long_hostname).unwrap_err(),
263 Error::TooLong
264 ));
265
266 let short_label = "a".repeat(61);
269 let valid_long = format!("{long_label}.{long_label}.{long_label}.{short_label}");
270 assert_eq!(valid_long.len(), 253);
271 assert!(Hostname::new(&valid_long).is_ok());
272 }
273
274 #[test]
275 fn test_hostname_invalid_label_too_long() {
276 let long_label = "a".repeat(64);
278 assert!(matches!(
279 Hostname::new(&long_label).unwrap_err(),
280 Error::LabelTooLong
281 ));
282
283 let valid_label = "a".repeat(63);
285 assert!(Hostname::new(&valid_label).is_ok());
286 }
287
288 #[test]
289 fn test_hostname_invalid_empty_label() {
290 assert!(matches!(
292 Hostname::new(".example.com").unwrap_err(),
293 Error::EmptyLabel
294 ));
295
296 assert!(matches!(
298 Hostname::new("example.com.").unwrap_err(),
299 Error::EmptyLabel
300 ));
301
302 assert!(matches!(
304 Hostname::new("example..com").unwrap_err(),
305 Error::EmptyLabel
306 ));
307 }
308
309 #[test]
310 fn test_hostname_invalid_characters() {
311 assert!(matches!(
313 Hostname::new("my_host.com").unwrap_err(),
314 Error::InvalidCharacter
315 ));
316
317 assert!(matches!(
319 Hostname::new("my host.com").unwrap_err(),
320 Error::InvalidCharacter
321 ));
322
323 assert!(matches!(
325 Hostname::new("host@example.com").unwrap_err(),
326 Error::InvalidCharacter
327 ));
328 assert!(matches!(
329 Hostname::new("host!.com").unwrap_err(),
330 Error::InvalidCharacter
331 ));
332
333 assert!(matches!(
335 Hostname::new("hôst.com").unwrap_err(),
336 Error::InvalidCharacter
337 ));
338 }
339
340 #[test]
341 fn test_hostname_invalid_hyphen_position() {
342 assert!(matches!(
344 Hostname::new("-example.com").unwrap_err(),
345 Error::LabelStartsWithHyphen
346 ));
347 assert!(matches!(
348 Hostname::new("example.-sub.com").unwrap_err(),
349 Error::LabelStartsWithHyphen
350 ));
351
352 assert!(matches!(
354 Hostname::new("example-.com").unwrap_err(),
355 Error::LabelEndsWithHyphen
356 ));
357 assert!(matches!(
358 Hostname::new("example.sub-.com").unwrap_err(),
359 Error::LabelEndsWithHyphen
360 ));
361
362 assert!(matches!(
364 Hostname::new("-").unwrap_err(),
365 Error::LabelStartsWithHyphen
366 ));
367 }
368
369 #[test]
370 fn test_hostname_try_from() {
371 let hostname: Result<Hostname, _> = "example.com".to_string().try_into();
373 assert!(hostname.is_ok());
374
375 let hostname: Result<Hostname, _> = "example.com".try_into();
377 assert!(hostname.is_ok());
378
379 let hostname: Result<Hostname, _> = "invalid..host".try_into();
381 assert!(hostname.is_err());
382 }
383
384 #[test]
385 fn test_hostname_display_and_as_ref() {
386 let hostname = Hostname::new("example.com").unwrap();
387 assert_eq!(format!("{hostname}"), "example.com");
388 assert_eq!(hostname.as_ref(), "example.com");
389 assert_eq!(hostname.as_str(), "example.com");
390 }
391
392 #[test]
393 fn test_hostname_into_string() {
394 let hostname = Hostname::new("example.com").unwrap();
395 let s: String = hostname.into_string();
396 assert_eq!(s, "example.com");
397 }
398
399 #[test]
400 fn test_hostname_macro() {
401 let h = hostname!("example.com");
402 assert_eq!(h.as_str(), "example.com");
403 }
404
405 #[cfg(feature = "arbitrary")]
406 mod conformance {
407 use super::*;
408 use commonware_codec::conformance::CodecConformance;
409
410 commonware_conformance::conformance_tests! {
411 CodecConformance<Hostname>,
412 }
413 }
414}