1use core::fmt;
37use core::str::FromStr;
38
39#[cfg(feature = "serde")]
40use serde::{Deserialize, Serialize};
41
42#[cfg(feature = "zeroize")]
43use zeroize::Zeroize;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48#[non_exhaustive]
49pub enum HostnameError {
50 Empty,
54 TooLong(usize),
59 LabelTooLong {
64 label: usize,
66 len: usize,
68 },
69 InvalidLabelStart(char),
75 InvalidLabelEnd(char),
81 InvalidChar(char),
86 EmptyLabel,
91}
92
93impl fmt::Display for HostnameError {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 match self {
96 Self::Empty => write!(f, "hostname cannot be empty"),
97 Self::TooLong(len) => write!(
98 f,
99 "hostname exceeds maximum length of 253 characters (got {len})"
100 ),
101 Self::LabelTooLong { label, len } => {
102 write!(
103 f,
104 "label {label} exceeds maximum length of 63 characters (got {len})"
105 )
106 }
107 Self::InvalidLabelStart(c) => write!(f, "label cannot start with '{c}'"),
108 Self::InvalidLabelEnd(c) => write!(f, "label cannot end with '{c}'"),
109 Self::InvalidChar(c) => write!(f, "invalid character '{c}' in hostname"),
110 Self::EmptyLabel => write!(f, "hostname cannot contain empty labels"),
111 }
112 }
113}
114
115#[cfg(feature = "std")]
116impl std::error::Error for HostnameError {}
117
118#[repr(transparent)]
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
155#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
156#[cfg_attr(feature = "zeroize", derive(Zeroize))]
157pub struct Hostname(heapless::String<253>);
158
159#[cfg(feature = "arbitrary")]
160impl<'a> arbitrary::Arbitrary<'a> for Hostname {
161 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
162 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
163 const DIGITS: &[u8] = b"0123456789";
164
165 let label_count = 1 + (u8::arbitrary(u)? % 4);
167 let mut inner = heapless::String::<253>::new();
168
169 for label_idx in 0..label_count {
170 let label_len = 1 + (u8::arbitrary(u)? % 20).min(19);
172
173 let first_byte = u8::arbitrary(u)?;
175 let first = ALPHABET[(first_byte % 26) as usize] as char;
176 inner
177 .push(first)
178 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
179
180 for _ in 1..label_len.saturating_sub(1) {
182 let byte = u8::arbitrary(u)?;
183 let c = match byte % 3 {
184 0 => ALPHABET[((byte >> 2) % 26) as usize] as char,
185 1 => DIGITS[((byte >> 2) % 10) as usize] as char,
186 _ => '-',
187 };
188 inner
189 .push(c)
190 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
191 }
192
193 if label_len > 1 {
195 let last_byte = u8::arbitrary(u)?;
196 let last = ALPHABET[(last_byte % 26) as usize] as char;
197 inner
198 .push(last)
199 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
200 }
201
202 if label_idx < label_count - 1 {
204 inner
205 .push('.')
206 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
207 }
208 }
209
210 Ok(Self(inner))
211 }
212}
213
214impl Hostname {
215 #[allow(clippy::missing_panics_doc)]
231 pub fn new(s: &str) -> Result<Self, HostnameError> {
232 if s.is_empty() {
233 return Err(HostnameError::Empty);
234 }
235
236 if s.len() > 253 {
237 return Err(HostnameError::TooLong(s.len()));
238 }
239
240 let mut inner = heapless::String::<253>::new();
241 let mut label_index = 0;
242 let mut label_len = 0;
243 let mut first_char: Option<char> = None;
244 let mut last_char: char = '\0';
245
246 for c in s.chars() {
247 if c == '.' {
248 if label_len == 0 {
249 return Err(HostnameError::EmptyLabel);
250 }
251
252 if label_len > 63 {
253 return Err(HostnameError::LabelTooLong {
254 label: label_index,
255 len: label_len,
256 });
257 }
258
259 let first = first_char.expect("label_len > 0 guarantees first_char is Some");
260 if !first.is_ascii_alphanumeric() {
261 return Err(HostnameError::InvalidLabelStart(first));
262 }
263
264 if !last_char.is_ascii_alphanumeric() {
265 return Err(HostnameError::InvalidLabelEnd(last_char));
266 }
267
268 inner.push('.').map_err(|_| HostnameError::TooLong(253))?;
269 label_index += 1;
270 label_len = 0;
271 first_char = None;
272 } else {
273 if !c.is_ascii() {
274 return Err(HostnameError::InvalidChar(c));
275 }
276
277 if !c.is_ascii_alphanumeric() && c != '-' {
278 return Err(HostnameError::InvalidChar(c));
279 }
280
281 if label_len == 0 {
282 first_char = Some(c);
283 }
284 last_char = c;
285 label_len += 1;
286
287 inner
288 .push(c.to_ascii_lowercase())
289 .map_err(|_| HostnameError::TooLong(253))?;
290 }
291 }
292
293 if label_len == 0 {
294 return Err(HostnameError::EmptyLabel);
295 }
296
297 if label_len > 63 {
298 return Err(HostnameError::LabelTooLong {
299 label: label_index,
300 len: label_len,
301 });
302 }
303
304 let first = first_char.expect("label_len > 0 guarantees first_char is Some");
305 if !first.is_ascii_alphanumeric() {
306 return Err(HostnameError::InvalidLabelStart(first));
307 }
308
309 if !last_char.is_ascii_alphanumeric() {
310 return Err(HostnameError::InvalidLabelEnd(last_char));
311 }
312
313 Ok(Self(inner))
314 }
315
316 #[must_use]
327 #[inline]
328 pub fn as_str(&self) -> &str {
329 &self.0
330 }
331
332 #[must_use]
344 #[inline]
345 pub const fn as_inner(&self) -> &heapless::String<253> {
346 &self.0
347 }
348
349 #[must_use]
361 #[inline]
362 pub fn into_inner(self) -> heapless::String<253> {
363 self.0
364 }
365
366 #[must_use]
377 #[inline]
378 pub fn is_localhost(&self) -> bool {
379 self.as_str() == "localhost"
380 }
381
382 pub fn labels(&self) -> impl Iterator<Item = &str> {
394 self.as_str().split('.')
395 }
396}
397
398impl TryFrom<&str> for Hostname {
399 type Error = HostnameError;
400
401 fn try_from(s: &str) -> Result<Self, Self::Error> {
402 Self::new(s)
403 }
404}
405
406impl From<Hostname> for heapless::String<253> {
407 fn from(hostname: Hostname) -> Self {
408 hostname.0
409 }
410}
411
412impl FromStr for Hostname {
413 type Err = HostnameError;
414
415 fn from_str(s: &str) -> Result<Self, Self::Err> {
416 Self::new(s)
417 }
418}
419
420impl fmt::Display for Hostname {
421 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422 write!(f, "{}", self.0)
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn test_new_valid_hostname() {
432 assert!(Hostname::new("example.com").is_ok());
433 assert!(Hostname::new("www.example.com").is_ok());
434 assert!(Hostname::new("localhost").is_ok());
435 assert!(Hostname::new("a").is_ok());
436 }
437
438 #[test]
439 fn test_empty_hostname() {
440 assert_eq!(Hostname::new(""), Err(HostnameError::Empty));
441 }
442
443 #[test]
444 fn test_too_long_hostname() {
445 let long = "a".repeat(254);
446 assert_eq!(Hostname::new(&long), Err(HostnameError::TooLong(254)));
447 }
448
449 #[test]
450 fn test_label_too_long() {
451 let long_label = "a".repeat(64);
452 assert_eq!(
453 Hostname::new(&long_label),
454 Err(HostnameError::LabelTooLong { label: 0, len: 64 })
455 );
456 }
457
458 #[test]
459 fn test_invalid_label_start() {
460 assert_eq!(
461 Hostname::new("-example.com"),
462 Err(HostnameError::InvalidLabelStart('-'))
463 );
464 }
465
466 #[test]
467 fn test_invalid_label_end() {
468 assert_eq!(
469 Hostname::new("example-.com"),
470 Err(HostnameError::InvalidLabelEnd('-'))
471 );
472 }
473
474 #[test]
475 fn test_invalid_char() {
476 assert_eq!(
477 Hostname::new("example_com"),
478 Err(HostnameError::InvalidChar('_'))
479 );
480 }
481
482 #[test]
483 fn test_empty_label() {
484 assert_eq!(
485 Hostname::new("example..com"),
486 Err(HostnameError::EmptyLabel)
487 );
488 assert_eq!(
489 Hostname::new(".example.com"),
490 Err(HostnameError::EmptyLabel)
491 );
492 assert_eq!(
493 Hostname::new("example.com."),
494 Err(HostnameError::EmptyLabel)
495 );
496 }
497
498 #[test]
499 fn test_as_str() {
500 let hostname = Hostname::new("example.com").unwrap();
501 assert_eq!(hostname.as_str(), "example.com");
502 }
503
504 #[test]
505 fn test_into_inner() {
506 let hostname = Hostname::new("example.com").unwrap();
507 let inner = hostname.into_inner();
508 assert_eq!(inner.as_str(), "example.com");
509 }
510
511 #[test]
512 fn test_is_localhost() {
513 assert!(Hostname::new("localhost").unwrap().is_localhost());
514 assert!(!Hostname::new("example.com").unwrap().is_localhost());
515 }
516
517 #[test]
518 fn test_labels() {
519 let hostname = Hostname::new("www.example.com").unwrap();
520 let labels: Vec<&str> = hostname.labels().collect();
521 assert_eq!(labels, vec!["www", "example", "com"]);
522 }
523
524 #[test]
525 fn test_labels_single() {
526 let hostname = Hostname::new("localhost").unwrap();
527 let labels: Vec<&str> = hostname.labels().collect();
528 assert_eq!(labels, vec!["localhost"]);
529 }
530
531 #[test]
532 fn test_try_from_str() {
533 let hostname = Hostname::try_from("example.com").unwrap();
534 assert_eq!(hostname.as_str(), "example.com");
535 }
536
537 #[test]
538 fn test_from_hostname_to_string() {
539 let hostname = Hostname::new("example.com").unwrap();
540 let inner: heapless::String<253> = hostname.into();
541 assert_eq!(inner.as_str(), "example.com");
542 }
543
544 #[test]
545 fn test_from_str() {
546 let hostname: Hostname = "example.com".parse().unwrap();
547 assert_eq!(hostname.as_str(), "example.com");
548 }
549
550 #[test]
551 fn test_from_str_invalid() {
552 assert!("".parse::<Hostname>().is_err());
553 assert!("-example.com".parse::<Hostname>().is_err());
554 assert!("example..com".parse::<Hostname>().is_err());
555 }
556
557 #[test]
558 fn test_display() {
559 let hostname = Hostname::new("example.com").unwrap();
560 assert_eq!(format!("{hostname}"), "example.com");
561 }
562
563 #[test]
564 fn test_equality() {
565 let hostname1 = Hostname::new("example.com").unwrap();
566 let hostname2 = Hostname::new("example.com").unwrap();
567 let hostname3 = Hostname::new("www.example.com").unwrap();
568
569 assert_eq!(hostname1, hostname2);
570 assert_ne!(hostname1, hostname3);
571 }
572
573 #[test]
574 fn test_ordering() {
575 let hostname1 = Hostname::new("a.example.com").unwrap();
576 let hostname2 = Hostname::new("b.example.com").unwrap();
577
578 assert!(hostname1 < hostname2);
579 }
580
581 #[test]
582 fn test_clone() {
583 let hostname = Hostname::new("example.com").unwrap();
584 let hostname2 = hostname.clone();
585 assert_eq!(hostname, hostname2);
586 }
587
588 #[test]
589 fn test_valid_characters() {
590 assert!(Hostname::new("a-b.example.com").is_ok());
591 assert!(Hostname::new("a1.example.com").is_ok());
592 assert!(Hostname::new("example-123.com").is_ok());
593 }
594
595 #[test]
596 fn test_maximum_length() {
597 let hostname = format!(
598 "{}.{}.{}.{}",
599 "a".repeat(63),
600 "b".repeat(63),
601 "c".repeat(63),
602 "d".repeat(61)
603 );
604 assert_eq!(hostname.len(), 253);
605 assert!(Hostname::new(&hostname).is_ok());
606 }
607
608 #[test]
609 fn test_error_display() {
610 assert_eq!(
611 format!("{}", HostnameError::Empty),
612 "hostname cannot be empty"
613 );
614 assert_eq!(
615 format!("{}", HostnameError::TooLong(300)),
616 "hostname exceeds maximum length of 253 characters (got 300)"
617 );
618 assert_eq!(
619 format!("{}", HostnameError::LabelTooLong { label: 0, len: 70 }),
620 "label 0 exceeds maximum length of 63 characters (got 70)"
621 );
622 assert_eq!(
623 format!("{}", HostnameError::InvalidLabelStart('-')),
624 "label cannot start with '-'"
625 );
626 assert_eq!(
627 format!("{}", HostnameError::InvalidLabelEnd('-')),
628 "label cannot end with '-'"
629 );
630 assert_eq!(
631 format!("{}", HostnameError::InvalidChar('_')),
632 "invalid character '_' in hostname"
633 );
634 assert_eq!(
635 format!("{}", HostnameError::EmptyLabel),
636 "hostname cannot contain empty labels"
637 );
638 }
639
640 #[test]
641 fn test_case_insensitive() {
642 let hostname1 = Hostname::new("Example.COM").unwrap();
643 let hostname2 = Hostname::new("example.com").unwrap();
644 assert_eq!(hostname1, hostname2);
645 assert_eq!(hostname1.as_str(), "example.com");
646 }
647}