Skip to main content

karbon_framework/validation/constraints/string/
hostname.rs

1use crate::validation::constraints::{Constraint, ConstraintResult, ConstraintViolation};
2
3/// Validates that a value is a valid hostname (RFC 1123).
4pub struct Hostname {
5    pub message: String,
6    pub require_tld: bool,
7}
8
9impl Default for Hostname {
10    fn default() -> Self {
11        Self {
12            message: "This value is not a valid hostname.".to_string(),
13            require_tld: true,
14        }
15    }
16}
17
18impl Hostname {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    pub fn with_message(mut self, message: impl Into<String>) -> Self {
24        self.message = message.into();
25        self
26    }
27
28    pub fn require_tld(mut self, require: bool) -> Self {
29        self.require_tld = require;
30        self
31    }
32
33    fn is_valid_hostname(&self, value: &str) -> bool {
34        if value.is_empty() || value.len() > 253 {
35            return false;
36        }
37
38        let labels: Vec<&str> = value.split('.').collect();
39
40        if self.require_tld && labels.len() < 2 {
41            return false;
42        }
43
44        for label in &labels {
45            if label.is_empty() || label.len() > 63 {
46                return false;
47            }
48            if label.starts_with('-') || label.ends_with('-') {
49                return false;
50            }
51            if !label.chars().all(|c| c.is_alphanumeric() || c == '-') {
52                return false;
53            }
54        }
55
56        // TLD must not be all-numeric
57        if self.require_tld {
58            if let Some(tld) = labels.last() {
59                if tld.chars().all(|c| c.is_ascii_digit()) {
60                    return false;
61                }
62            }
63        }
64
65        true
66    }
67}
68
69impl Constraint for Hostname {
70    fn validate(&self, value: &str) -> ConstraintResult {
71        if !self.is_valid_hostname(value) {
72            return Err(ConstraintViolation::new(
73                self.name(),
74                &self.message,
75                value,
76            ));
77        }
78        Ok(())
79    }
80
81    fn name(&self) -> &'static str {
82        "Hostname"
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_valid_hostnames() {
92        let constraint = Hostname::new();
93        assert!(constraint.validate("example.com").is_ok());
94        assert!(constraint.validate("sub.example.com").is_ok());
95        assert!(constraint.validate("my-host.example.org").is_ok());
96        assert!(constraint.validate("a.io").is_ok());
97    }
98
99    #[test]
100    fn test_invalid_hostnames() {
101        let constraint = Hostname::new();
102        assert!(constraint.validate("").is_err());
103        assert!(constraint.validate("-example.com").is_err());
104        assert!(constraint.validate("example-.com").is_err());
105        assert!(constraint.validate("exam ple.com").is_err());
106        assert!(constraint.validate("example.123").is_err()); // numeric TLD
107    }
108
109    #[test]
110    fn test_without_tld_requirement() {
111        let constraint = Hostname::new().require_tld(false);
112        assert!(constraint.validate("localhost").is_ok());
113        assert!(constraint.validate("myserver").is_ok());
114    }
115
116    #[test]
117    fn test_with_tld_requirement() {
118        let constraint = Hostname::new();
119        assert!(constraint.validate("localhost").is_err());
120    }
121}