Coverage Report

Created: 2026-02-05 14:27

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/lyquor/lyquor/barstrainer/src/validation.rs
Line
Count
Source
1
use std::collections::BTreeSet;
2
use std::net::{Ipv4Addr, Ipv6Addr};
3
use std::str::FromStr;
4
5
use crate::dns::RecordType;
6
use lyquor_primitives::NodeID;
7
8
const ACME_CHALLENGE_PREFIX: &str = "_acme-challenge.";
9
const MAX_DOMAIN_LEN: usize = 253;
10
const MAX_LABEL_LEN: usize = 63;
11
12
#[derive(Debug, thiserror::Error)]
13
pub enum RequestError {
14
    #[error("invalid argument: {0}")]
15
    InvalidArgument(String),
16
    #[error("permission denied: {0}")]
17
    PermissionDenied(String),
18
}
19
20
13
pub fn normalize_fqdn(input: &str) -> Result<String, RequestError> {
21
13
    let trimmed = input.trim();
22
13
    if trimmed.is_empty() {
23
0
        return Err(RequestError::InvalidArgument("fqdn is required".into()));
24
13
    }
25
26
13
    let lower = trimmed.to_ascii_lowercase();
27
13
    let normalized = lower.trim_end_matches('.');
28
13
    if normalized.is_empty() {
29
0
        return Err(RequestError::InvalidArgument(
30
0
            "fqdn is empty after normalization".into(),
31
0
        ));
32
13
    }
33
13
    if normalized.len() > MAX_DOMAIN_LEN {
34
0
        return Err(RequestError::InvalidArgument("fqdn exceeds maximum length".into()));
35
13
    }
36
37
13
    let labels: Vec<&str> = normalized.split('.').collect();
38
13
    validate_labels(&labels)
?0
;
39
40
13
    Ok(normalized.to_string())
41
13
}
42
43
2
pub fn normalize_cname_target(input: &str) -> Result<String, RequestError> {
44
2
    let normalized = normalize_fqdn(input)
?0
;
45
2
    Ok(normalized)
46
2
}
47
48
10
pub fn normalize_ttl(ttl: u32, default_ttl: u32) -> u32 {
49
10
    if ttl == 0 { 
default_ttl4
} else {
ttl6
}
50
10
}
51
52
12
pub fn normalize_record_values(record_type: RecordType, values: Vec<String>) -> Result<Vec<String>, RequestError> {
53
12
    match record_type {
54
6
        RecordType::A => normalize_ipv4_values(values),
55
3
        RecordType::Aaaa => normalize_ipv6_values(values),
56
2
        RecordType::Txt => normalize_txt_values(values),
57
1
        RecordType::Cname => normalize_cname_values(values),
58
    }
59
12
}
60
61
14
pub fn validate_fqdn_for_record(
62
14
    fqdn: &str, record_type: RecordType, dns_suffix: &str, node_id: &NodeID,
63
14
) -> Result<(), RequestError> {
64
14
    match record_type {
65
        RecordType::Txt => {
66
3
            if !fqdn.starts_with(ACME_CHALLENGE_PREFIX) {
67
0
                return Err(RequestError::PermissionDenied(
68
0
                    "TXT records must target _acme-challenge names".into(),
69
0
                ));
70
3
            }
71
3
            let base = &fqdn[ACME_CHALLENGE_PREFIX.len()..];
72
3
            if base.is_empty() {
73
0
                return Err(RequestError::PermissionDenied(
74
0
                    "_acme-challenge base name is empty".into(),
75
0
                ));
76
3
            }
77
3
            ensure_allowed_suffix(base, dns_suffix)
?0
;
78
3
            ensure_node_owns_fqdn(base, dns_suffix, node_id)
?0
;
79
        }
80
        RecordType::A | RecordType::Aaaa | RecordType::Cname => {
81
11
            if fqdn.starts_with(ACME_CHALLENGE_PREFIX) {
82
1
                return Err(RequestError::PermissionDenied(
83
1
                    "non-TXT records may not target _acme-challenge names".into(),
84
1
                ));
85
10
            }
86
10
            ensure_allowed_suffix(fqdn, dns_suffix)
?1
;
87
9
            ensure_node_owns_fqdn(fqdn, dns_suffix, node_id)
?1
;
88
        }
89
    }
90
91
11
    Ok(())
92
14
}
93
94
6
fn normalize_ipv4_values(values: Vec<String>) -> Result<Vec<String>, RequestError> {
95
6
    let mut set = BTreeSet::new();
96
8
    for value in 
values6
{
97
8
        let trimmed = value.trim();
98
8
        if trimmed.is_empty() {
99
0
            return Err(RequestError::InvalidArgument("A record value is empty".into()));
100
8
        }
101
8
        let 
addr7
= Ipv4Addr::from_str(trimmed)
102
8
            .map_err(|_| RequestError::InvalidArgument(
format!1
("invalid IPv4 address: {trimmed}")))
?1
;
103
7
        set.insert(addr.to_string());
104
    }
105
5
    Ok(set.into_iter().collect())
106
6
}
107
108
3
fn normalize_ipv6_values(values: Vec<String>) -> Result<Vec<String>, RequestError> {
109
3
    let mut set = BTreeSet::new();
110
3
    for value in values {
111
3
        let trimmed = value.trim();
112
3
        if trimmed.is_empty() {
113
0
            return Err(RequestError::InvalidArgument("AAAA record value is empty".into()));
114
3
        }
115
3
        let 
addr2
= Ipv6Addr::from_str(trimmed)
116
3
            .map_err(|_| RequestError::InvalidArgument(
format!1
("invalid IPv6 address: {trimmed}")))
?1
;
117
2
        set.insert(addr.to_string());
118
    }
119
2
    Ok(set.into_iter().collect())
120
3
}
121
122
2
fn normalize_txt_values(values: Vec<String>) -> Result<Vec<String>, RequestError> {
123
2
    let mut set = BTreeSet::new();
124
2
    for value in values {
125
2
        set.insert(value);
126
2
    }
127
2
    Ok(set.into_iter().collect())
128
2
}
129
130
1
fn normalize_cname_values(values: Vec<String>) -> Result<Vec<String>, RequestError> {
131
1
    if values.len() > 1 {
132
1
        return Err(RequestError::InvalidArgument(
133
1
            "CNAME must contain exactly one value".into(),
134
1
        ));
135
0
    }
136
0
    if values.is_empty() {
137
0
        return Ok(Vec::new());
138
0
    }
139
0
    let target = normalize_cname_target(&values[0])?;
140
0
    Ok(vec![target])
141
1
}
142
143
13
fn validate_labels(labels: &[&str]) -> Result<(), RequestError> {
144
57
    for label in 
labels13
{
145
57
        if label.is_empty() {
146
0
            return Err(RequestError::InvalidArgument("fqdn has empty label".into()));
147
57
        }
148
57
        if label.len() > MAX_LABEL_LEN {
149
0
            return Err(RequestError::InvalidArgument(
150
0
                "fqdn label exceeds maximum length".into(),
151
0
            ));
152
57
        }
153
57
        if !label
154
57
            .chars()
155
870
            .
all57
(|c| c.is_ascii_lowercase() ||
c88
.
is_ascii_digit88
() ||
c == '-'16
||
c == '_'3
)
156
        {
157
0
            return Err(RequestError::InvalidArgument(format!(
158
0
                "fqdn label contains invalid character: {label}"
159
0
            )));
160
57
        }
161
    }
162
13
    Ok(())
163
13
}
164
165
13
fn ensure_allowed_suffix(fqdn: &str, dns_suffix: &str) -> Result<(), RequestError> {
166
13
    if !fqdn.ends_with(dns_suffix) {
167
1
        return Err(RequestError::PermissionDenied("fqdn is outside allowed suffix".into()));
168
12
    }
169
170
12
    let prefix = fqdn.strip_suffix(dns_suffix).unwrap_or("");
171
12
    let prefix = prefix.trim_end_matches('.');
172
12
    if prefix.is_empty() {
173
0
        return Err(RequestError::PermissionDenied(
174
0
            "fqdn must include node label within allowed suffix".into(),
175
0
        ));
176
12
    }
177
178
12
    Ok(())
179
13
}
180
181
12
fn ensure_node_owns_fqdn(fqdn: &str, dns_suffix: &str, node_id: &NodeID) -> Result<(), RequestError> {
182
12
    let prefix = fqdn.strip_suffix(dns_suffix).unwrap_or("").trim_end_matches('.');
183
12
    if prefix.is_empty() {
184
0
        return Err(RequestError::PermissionDenied(
185
0
            "fqdn must include node label within allowed suffix".into(),
186
0
        ));
187
12
    }
188
189
    // NodeID::as_dns_label is the canonical label used across lyquor-tls.
190
12
    let label = node_id.as_dns_label();
191
12
    let mut labels = prefix.split('.');
192
12
    let last = labels.next_back().unwrap_or("");
193
12
    if last != label {
194
1
        return Err(RequestError::PermissionDenied("fqdn is not owned by node".into()));
195
11
    }
196
197
11
    Ok(())
198
12
}