1#[derive(Debug, Clone, PartialEq, Eq)]
2pub enum NameError {
3 DanglingEscape,
4 InvalidEmail,
5}
6
7impl std::fmt::Display for NameError {
8 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9 match self {
10 NameError::DanglingEscape => write!(f, "domain name contains a dangling escape"),
11 NameError::InvalidEmail => write!(f, "email must contain exactly one @"),
12 }
13 }
14}
15
16impl std::error::Error for NameError {}
17
18pub fn split_presentation_labels(name: &str) -> Result<Vec<String>, NameError> {
19 let mut labels = Vec::new();
20 let mut label = String::new();
21 let mut escaped = false;
22
23 for c in name.chars() {
24 if escaped {
25 label.push(c);
26 escaped = false;
27 continue;
28 }
29
30 match c {
31 '\\' => escaped = true,
32 '.' => {
33 labels.push(label);
34 label = String::new();
35 }
36 _ => label.push(c),
37 }
38 }
39
40 if escaped {
41 return Err(NameError::DanglingEscape);
42 }
43
44 labels.push(label);
45 Ok(labels)
46}
47
48pub fn to_fqdn_lowercase(value: &str) -> String {
49 format!(
50 "{}.",
51 value.trim().trim_end_matches('.').to_ascii_lowercase()
52 )
53}
54
55pub fn to_fqdn(value: &str) -> String {
56 format!("{}.", value.trim_end_matches('.'))
57}
58
59pub fn is_same_or_subdomain_fqdn(name: &str, zone: &str) -> bool {
60 name == zone || name.ends_with(&format!(".{}", zone))
61}
62
63pub fn to_relative_domain(fqdn: &str, zone_name: &str) -> String {
64 let fqdn = to_fqdn(fqdn);
65 let zone = to_fqdn(zone_name);
66
67 if fqdn.eq_ignore_ascii_case(&zone) {
68 return "@".to_string();
69 }
70
71 let fqdn_lower = fqdn.to_ascii_lowercase();
72 let zone_lower = zone.to_ascii_lowercase();
73
74 if is_same_or_subdomain_fqdn(&fqdn_lower, &zone_lower) {
75 let relative_part = &fqdn[..fqdn.len() - zone.len()];
76 relative_part.trim_end_matches('.').to_string()
77 } else {
78 fqdn.trim_end_matches('.').to_string()
79 }
80}
81
82pub fn is_in_bailiwick(name: &str, zone_name: &str) -> bool {
83 let name = to_fqdn(name).to_ascii_lowercase();
84 let zone = to_fqdn(zone_name).to_ascii_lowercase();
85
86 is_same_or_subdomain_fqdn(&name, &zone)
87}
88
89pub fn is_apex_name(name: &str, zone_name: &str) -> bool {
90 name == "@" || to_fqdn(name).eq_ignore_ascii_case(&to_fqdn(zone_name))
91}
92
93pub fn email_to_soa_mailbox(value: &str) -> Result<String, NameError> {
94 if value.matches('@').count() != 1 {
95 return Err(NameError::InvalidEmail);
96 }
97
98 let (local, domain) = value.split_once('@').ok_or(NameError::InvalidEmail)?;
99
100 Ok(format!(
101 "{}.{}.",
102 escape_soa_local_part(local),
103 domain.trim_end_matches('.')
104 ))
105}
106
107fn escape_soa_local_part(local: &str) -> String {
108 let mut escaped = String::with_capacity(local.len());
109
110 for c in local.chars() {
111 if c == '.' || c == '\\' {
112 escaped.push('\\');
113 }
114 escaped.push(c);
115 }
116
117 escaped
118}
119
120#[cfg(test)]
121mod tests {
122 use super::{is_in_bailiwick, to_relative_domain};
123
124 #[test]
125 fn is_in_bailiwick_accepts_apex_and_subdomain() {
126 assert!(is_in_bailiwick("example.com.", "example.com."));
127 assert!(is_in_bailiwick("ns.example.com.", "example.com."));
128 }
129
130 #[test]
131 fn is_in_bailiwick_rejects_sibling_suffix_match() {
132 assert!(!is_in_bailiwick("notexample.com.", "example.com."));
133 assert!(!is_in_bailiwick("ns.notexample.com.", "example.com."));
134 }
135
136 #[test]
137 fn to_relative_domain_converts_only_zone_apex_and_subdomains() {
138 assert_eq!(to_relative_domain("example.com.", "example.com."), "@");
139 assert_eq!(to_relative_domain("ns.example.com.", "example.com."), "ns");
140 assert_eq!(
141 to_relative_domain("notexample.com.", "example.com."),
142 "notexample.com"
143 );
144 }
145}