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))]
48pub enum HostnameError {
49 Empty,
51 TooLong(usize),
53 LabelTooLong {
55 label: usize,
57 len: usize,
59 },
60 InvalidLabelStart(char),
62 InvalidLabelEnd(char),
64 InvalidChar(char),
66 EmptyLabel,
68}
69
70impl fmt::Display for HostnameError {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 Self::Empty => write!(f, "hostname cannot be empty"),
74 Self::TooLong(len) => write!(
75 f,
76 "hostname exceeds maximum length of 253 characters (got {len})"
77 ),
78 Self::LabelTooLong { label, len } => {
79 write!(
80 f,
81 "label {label} exceeds maximum length of 63 characters (got {len})"
82 )
83 }
84 Self::InvalidLabelStart(c) => write!(f, "label cannot start with '{c}'"),
85 Self::InvalidLabelEnd(c) => write!(f, "label cannot end with '{c}'"),
86 Self::InvalidChar(c) => write!(f, "invalid character '{c}' in hostname"),
87 Self::EmptyLabel => write!(f, "hostname cannot contain empty labels"),
88 }
89 }
90}
91
92#[cfg(feature = "std")]
93impl std::error::Error for HostnameError {}
94
95#[repr(transparent)]
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
132#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
133#[cfg_attr(feature = "zeroize", derive(Zeroize))]
134pub struct Hostname(heapless::String<253>);
135
136#[cfg(feature = "arbitrary")]
137impl<'a> arbitrary::Arbitrary<'a> for Hostname {
138 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
139 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
140 const DIGITS: &[u8] = b"0123456789";
141
142 let label_count = 1 + (u8::arbitrary(u)? % 4);
144 let mut inner = heapless::String::<253>::new();
145
146 for label_idx in 0..label_count {
147 let label_len = 1 + (u8::arbitrary(u)? % 20).min(19);
149
150 let first_byte = u8::arbitrary(u)?;
152 let first = ALPHABET[(first_byte % 26) as usize] as char;
153 inner
154 .push(first)
155 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
156
157 for _ in 1..label_len.saturating_sub(1) {
159 let byte = u8::arbitrary(u)?;
160 let c = match byte % 3 {
161 0 => ALPHABET[((byte >> 2) % 26) as usize] as char,
162 1 => DIGITS[((byte >> 2) % 10) as usize] as char,
163 _ => '-',
164 };
165 inner
166 .push(c)
167 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
168 }
169
170 if label_len > 1 {
172 let last_byte = u8::arbitrary(u)?;
173 let last = ALPHABET[(last_byte % 26) as usize] as char;
174 inner
175 .push(last)
176 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
177 }
178
179 if label_idx < label_count - 1 {
181 inner
182 .push('.')
183 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
184 }
185 }
186
187 Ok(Self(inner))
188 }
189}
190
191impl Hostname {
192 #[allow(clippy::missing_panics_doc)]
208 pub fn new(s: &str) -> Result<Self, HostnameError> {
209 if s.is_empty() {
210 return Err(HostnameError::Empty);
211 }
212
213 if s.len() > 253 {
214 return Err(HostnameError::TooLong(s.len()));
215 }
216
217 let mut inner = heapless::String::<253>::new();
218 let mut label_index = 0;
219 let mut label_len = 0;
220 let mut first_char: Option<char> = None;
221 let mut last_char: char = '\0';
222
223 for c in s.chars() {
224 if c == '.' {
225 if label_len == 0 {
226 return Err(HostnameError::EmptyLabel);
227 }
228
229 if label_len > 63 {
230 return Err(HostnameError::LabelTooLong {
231 label: label_index,
232 len: label_len,
233 });
234 }
235
236 let first = first_char.expect("label_len > 0 guarantees first_char is Some");
237 if !first.is_ascii_alphanumeric() {
238 return Err(HostnameError::InvalidLabelStart(first));
239 }
240
241 if !last_char.is_ascii_alphanumeric() {
242 return Err(HostnameError::InvalidLabelEnd(last_char));
243 }
244
245 inner.push('.').map_err(|_| HostnameError::TooLong(253))?;
246 label_index += 1;
247 label_len = 0;
248 first_char = None;
249 } else {
250 if !c.is_ascii() {
251 return Err(HostnameError::InvalidChar(c));
252 }
253
254 if !c.is_ascii_alphanumeric() && c != '-' {
255 return Err(HostnameError::InvalidChar(c));
256 }
257
258 if label_len == 0 {
259 first_char = Some(c);
260 }
261 last_char = c;
262 label_len += 1;
263
264 inner
265 .push(c.to_ascii_lowercase())
266 .map_err(|_| HostnameError::TooLong(253))?;
267 }
268 }
269
270 if label_len == 0 {
271 return Err(HostnameError::EmptyLabel);
272 }
273
274 if label_len > 63 {
275 return Err(HostnameError::LabelTooLong {
276 label: label_index,
277 len: label_len,
278 });
279 }
280
281 let first = first_char.expect("label_len > 0 guarantees first_char is Some");
282 if !first.is_ascii_alphanumeric() {
283 return Err(HostnameError::InvalidLabelStart(first));
284 }
285
286 if !last_char.is_ascii_alphanumeric() {
287 return Err(HostnameError::InvalidLabelEnd(last_char));
288 }
289
290 Ok(Self(inner))
291 }
292
293 #[must_use]
304 #[inline]
305 pub fn as_str(&self) -> &str {
306 &self.0
307 }
308
309 #[must_use]
321 #[inline]
322 pub const fn as_inner(&self) -> &heapless::String<253> {
323 &self.0
324 }
325
326 #[must_use]
338 #[inline]
339 pub fn into_inner(self) -> heapless::String<253> {
340 self.0
341 }
342
343 #[must_use]
354 #[inline]
355 pub fn is_localhost(&self) -> bool {
356 self.as_str() == "localhost"
357 }
358
359 pub fn labels(&self) -> impl Iterator<Item = &str> {
371 self.as_str().split('.')
372 }
373}
374
375impl TryFrom<&str> for Hostname {
376 type Error = HostnameError;
377
378 fn try_from(s: &str) -> Result<Self, Self::Error> {
379 Self::new(s)
380 }
381}
382
383impl From<Hostname> for heapless::String<253> {
384 fn from(hostname: Hostname) -> Self {
385 hostname.0
386 }
387}
388
389impl FromStr for Hostname {
390 type Err = HostnameError;
391
392 fn from_str(s: &str) -> Result<Self, Self::Err> {
393 Self::new(s)
394 }
395}
396
397impl fmt::Display for Hostname {
398 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399 write!(f, "{}", self.0)
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_new_valid_hostname() {
409 assert!(Hostname::new("example.com").is_ok());
410 assert!(Hostname::new("www.example.com").is_ok());
411 assert!(Hostname::new("localhost").is_ok());
412 assert!(Hostname::new("a").is_ok());
413 }
414
415 #[test]
416 fn test_empty_hostname() {
417 assert_eq!(Hostname::new(""), Err(HostnameError::Empty));
418 }
419
420 #[test]
421 fn test_too_long_hostname() {
422 let long = "a".repeat(254);
423 assert_eq!(Hostname::new(&long), Err(HostnameError::TooLong(254)));
424 }
425
426 #[test]
427 fn test_label_too_long() {
428 let long_label = "a".repeat(64);
429 assert_eq!(
430 Hostname::new(&long_label),
431 Err(HostnameError::LabelTooLong { label: 0, len: 64 })
432 );
433 }
434
435 #[test]
436 fn test_invalid_label_start() {
437 assert_eq!(
438 Hostname::new("-example.com"),
439 Err(HostnameError::InvalidLabelStart('-'))
440 );
441 }
442
443 #[test]
444 fn test_invalid_label_end() {
445 assert_eq!(
446 Hostname::new("example-.com"),
447 Err(HostnameError::InvalidLabelEnd('-'))
448 );
449 }
450
451 #[test]
452 fn test_invalid_char() {
453 assert_eq!(
454 Hostname::new("example_com"),
455 Err(HostnameError::InvalidChar('_'))
456 );
457 }
458
459 #[test]
460 fn test_empty_label() {
461 assert_eq!(
462 Hostname::new("example..com"),
463 Err(HostnameError::EmptyLabel)
464 );
465 assert_eq!(
466 Hostname::new(".example.com"),
467 Err(HostnameError::EmptyLabel)
468 );
469 assert_eq!(
470 Hostname::new("example.com."),
471 Err(HostnameError::EmptyLabel)
472 );
473 }
474
475 #[test]
476 fn test_as_str() {
477 let hostname = Hostname::new("example.com").unwrap();
478 assert_eq!(hostname.as_str(), "example.com");
479 }
480
481 #[test]
482 fn test_into_inner() {
483 let hostname = Hostname::new("example.com").unwrap();
484 let inner = hostname.into_inner();
485 assert_eq!(inner.as_str(), "example.com");
486 }
487
488 #[test]
489 fn test_is_localhost() {
490 assert!(Hostname::new("localhost").unwrap().is_localhost());
491 assert!(!Hostname::new("example.com").unwrap().is_localhost());
492 }
493
494 #[test]
495 fn test_labels() {
496 let hostname = Hostname::new("www.example.com").unwrap();
497 let labels: Vec<&str> = hostname.labels().collect();
498 assert_eq!(labels, vec!["www", "example", "com"]);
499 }
500
501 #[test]
502 fn test_labels_single() {
503 let hostname = Hostname::new("localhost").unwrap();
504 let labels: Vec<&str> = hostname.labels().collect();
505 assert_eq!(labels, vec!["localhost"]);
506 }
507
508 #[test]
509 fn test_try_from_str() {
510 let hostname = Hostname::try_from("example.com").unwrap();
511 assert_eq!(hostname.as_str(), "example.com");
512 }
513
514 #[test]
515 fn test_from_hostname_to_string() {
516 let hostname = Hostname::new("example.com").unwrap();
517 let inner: heapless::String<253> = hostname.into();
518 assert_eq!(inner.as_str(), "example.com");
519 }
520
521 #[test]
522 fn test_from_str() {
523 let hostname: Hostname = "example.com".parse().unwrap();
524 assert_eq!(hostname.as_str(), "example.com");
525 }
526
527 #[test]
528 fn test_from_str_invalid() {
529 assert!("".parse::<Hostname>().is_err());
530 assert!("-example.com".parse::<Hostname>().is_err());
531 assert!("example..com".parse::<Hostname>().is_err());
532 }
533
534 #[test]
535 fn test_display() {
536 let hostname = Hostname::new("example.com").unwrap();
537 assert_eq!(format!("{hostname}"), "example.com");
538 }
539
540 #[test]
541 fn test_equality() {
542 let hostname1 = Hostname::new("example.com").unwrap();
543 let hostname2 = Hostname::new("example.com").unwrap();
544 let hostname3 = Hostname::new("www.example.com").unwrap();
545
546 assert_eq!(hostname1, hostname2);
547 assert_ne!(hostname1, hostname3);
548 }
549
550 #[test]
551 fn test_ordering() {
552 let hostname1 = Hostname::new("a.example.com").unwrap();
553 let hostname2 = Hostname::new("b.example.com").unwrap();
554
555 assert!(hostname1 < hostname2);
556 }
557
558 #[test]
559 fn test_clone() {
560 let hostname = Hostname::new("example.com").unwrap();
561 let hostname2 = hostname.clone();
562 assert_eq!(hostname, hostname2);
563 }
564
565 #[test]
566 fn test_valid_characters() {
567 assert!(Hostname::new("a-b.example.com").is_ok());
568 assert!(Hostname::new("a1.example.com").is_ok());
569 assert!(Hostname::new("example-123.com").is_ok());
570 }
571
572 #[test]
573 fn test_maximum_length() {
574 let hostname = format!(
575 "{}.{}.{}.{}",
576 "a".repeat(63),
577 "b".repeat(63),
578 "c".repeat(63),
579 "d".repeat(61)
580 );
581 assert_eq!(hostname.len(), 253);
582 assert!(Hostname::new(&hostname).is_ok());
583 }
584
585 #[test]
586 fn test_error_display() {
587 assert_eq!(
588 format!("{}", HostnameError::Empty),
589 "hostname cannot be empty"
590 );
591 assert_eq!(
592 format!("{}", HostnameError::TooLong(300)),
593 "hostname exceeds maximum length of 253 characters (got 300)"
594 );
595 assert_eq!(
596 format!("{}", HostnameError::LabelTooLong { label: 0, len: 70 }),
597 "label 0 exceeds maximum length of 63 characters (got 70)"
598 );
599 assert_eq!(
600 format!("{}", HostnameError::InvalidLabelStart('-')),
601 "label cannot start with '-'"
602 );
603 assert_eq!(
604 format!("{}", HostnameError::InvalidLabelEnd('-')),
605 "label cannot end with '-'"
606 );
607 assert_eq!(
608 format!("{}", HostnameError::InvalidChar('_')),
609 "invalid character '_' in hostname"
610 );
611 assert_eq!(
612 format!("{}", HostnameError::EmptyLabel),
613 "hostname cannot contain empty labels"
614 );
615 }
616
617 #[test]
618 fn test_case_insensitive() {
619 let hostname1 = Hostname::new("Example.COM").unwrap();
620 let hostname2 = Hostname::new("example.com").unwrap();
621 assert_eq!(hostname1, hostname2);
622 assert_eq!(hostname1.as_str(), "example.com");
623 }
624}