Skip to main content

use_host/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::net::IpAddr;
5
6/// Classifies a host-like input.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum HostKind {
9    /// IP literal host.
10    Ip,
11    /// Domain or hostname-like host.
12    Domain,
13    /// The special `localhost` host.
14    Localhost,
15    /// Unknown or invalid host input.
16    Unknown,
17}
18
19/// Stores a normalized host and its detected kind.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Host {
22    /// Normalized host value.
23    pub value: String,
24    /// Detected host kind.
25    pub kind: HostKind,
26}
27
28fn is_valid_label(label: &str) -> bool {
29    !label.is_empty()
30        && label.len() <= 63
31        && !label.starts_with('-')
32        && !label.ends_with('-')
33        && label
34            .chars()
35            .all(|character| character.is_ascii_alphanumeric() || character == '-')
36}
37
38fn looks_like_hostname(input: &str) -> bool {
39    let trimmed = input.trim_end_matches('.');
40
41    !trimmed.is_empty()
42        && trimmed.len() <= 253
43        && !trimmed.contains(':')
44        && !trimmed.contains('/')
45        && !trimmed.chars().any(char::is_whitespace)
46        && trimmed.split('.').all(is_valid_label)
47}
48
49/// Removes surrounding IPv6-style brackets when present.
50pub fn strip_brackets(input: &str) -> &str {
51    if input.len() >= 2 && input.starts_with('[') && input.ends_with(']') {
52        &input[1..input.len() - 1]
53    } else {
54        input
55    }
56}
57
58/// Adds brackets around an IPv6 host and leaves other hosts unchanged.
59pub fn bracket_ipv6_host(input: &str) -> String {
60    let trimmed = input.trim();
61    let stripped = strip_brackets(trimmed).trim();
62
63    match stripped.parse::<IpAddr>() {
64        Ok(IpAddr::V6(address)) => format!("[{address}]"),
65        _ => trimmed.to_string(),
66    }
67}
68
69/// Detects the host kind for a host-like input.
70pub fn detect_host_kind(input: &str) -> HostKind {
71    let trimmed = input.trim();
72
73    if trimmed.is_empty() {
74        return HostKind::Unknown;
75    }
76
77    let candidate = strip_brackets(trimmed).trim();
78
79    if candidate.eq_ignore_ascii_case("localhost") {
80        HostKind::Localhost
81    } else if candidate.parse::<IpAddr>().is_ok() {
82        HostKind::Ip
83    } else if looks_like_hostname(&candidate.to_ascii_lowercase()) {
84        HostKind::Domain
85    } else {
86        HostKind::Unknown
87    }
88}
89
90/// Parses and normalizes a host-like input.
91pub fn parse_host(input: &str) -> Option<Host> {
92    let value = normalize_host(input)?;
93    let kind = detect_host_kind(&value);
94
95    Some(Host { value, kind })
96}
97
98/// Returns `true` when the input is `localhost`.
99pub fn is_localhost(input: &str) -> bool {
100    matches!(detect_host_kind(input), HostKind::Localhost)
101}
102
103/// Returns `true` when the input is an IP literal host.
104pub fn is_ip_host(input: &str) -> bool {
105    matches!(detect_host_kind(input), HostKind::Ip)
106}
107
108/// Returns `true` when the input is a domain or hostname-like host.
109pub fn is_domain_host(input: &str) -> bool {
110    matches!(detect_host_kind(input), HostKind::Domain)
111}
112
113/// Normalizes a host-like input.
114pub fn normalize_host(input: &str) -> Option<String> {
115    let trimmed = input.trim();
116
117    if trimmed.is_empty() {
118        return None;
119    }
120
121    let candidate = strip_brackets(trimmed).trim();
122
123    if candidate.eq_ignore_ascii_case("localhost") {
124        return Some(String::from("localhost"));
125    }
126
127    if let Ok(address) = candidate.parse::<IpAddr>() {
128        return Some(address.to_string());
129    }
130
131    let normalized = candidate.trim_end_matches('.').to_ascii_lowercase();
132
133    if looks_like_hostname(&normalized) {
134        Some(normalized)
135    } else {
136        None
137    }
138}