rustlavel_validation/
check.rs1pub fn is_email(value: &str) -> bool {
16 if value.len() > 254 {
18 return false;
19 }
20 let Some((local, domain)) = value.split_once('@') else {
21 return false;
22 };
23 !domain.contains('@') && is_email_local(local) && is_domain(domain)
25}
26
27fn is_email_local(local: &str) -> bool {
28 if local.is_empty() || local.len() > 64 {
29 return false;
30 }
31 if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
32 return false;
33 }
34 local
35 .chars()
36 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-/=?^_`{|}~.".contains(c))
37}
38
39fn is_domain(domain: &str) -> bool {
41 if domain.is_empty() || domain.len() > 253 {
42 return false;
43 }
44 let labels: Vec<&str> = domain.split('.').collect();
45 if labels.len() < 2 {
46 return false;
47 }
48 let well_formed = labels.iter().all(|label| {
49 !label.is_empty()
50 && label.len() <= 63
51 && !label.starts_with('-')
52 && !label.ends_with('-')
53 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
54 });
55 let tld = labels[labels.len() - 1];
56 well_formed && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic())
57}
58
59pub fn is_url(value: &str) -> bool {
66 if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
67 return false;
68 }
69 let Some((scheme, rest)) = value.split_once("://") else {
70 return false;
71 };
72 if !scheme.starts_with(|c: char| c.is_ascii_alphabetic())
73 || !scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
74 {
75 return false;
76 }
77
78 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
80 let host = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
81 let host = strip_port(host);
82 !host.is_empty()
83 && host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | ':' | '[' | ']'))
84}
85
86fn strip_port(host: &str) -> &str {
88 if host.starts_with('[') {
89 return host;
90 }
91 match host.rsplit_once(':') {
92 Some((head, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => head,
93 _ => host,
94 }
95}
96
97pub fn is_date(value: &str) -> bool {
102 let bytes = value.as_bytes();
103 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
104 return false;
105 }
106 let (Some(year), Some(month), Some(day)) =
107 (digits(&value[0..4]), digits(&value[5..7]), digits(&value[8..10]))
108 else {
109 return false;
110 };
111 (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month)
112}
113
114fn digits(part: &str) -> Option<u32> {
116 if part.bytes().all(|b| b.is_ascii_digit()) { part.parse().ok() } else { None }
117}
118
119fn days_in_month(year: u32, month: u32) -> u32 {
120 match month {
121 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
122 4 | 6 | 9 | 11 => 30,
123 2 if is_leap_year(year) => 29,
124 2 => 28,
125 _ => 0,
126 }
127}
128
129fn is_leap_year(year: u32) -> bool {
130 year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
131}
132
133pub fn is_uuid(value: &str) -> bool {
139 let mut groups = value.split('-');
140 for length in [8, 4, 4, 4, 12] {
141 match groups.next() {
142 Some(group)
143 if group.len() == length && group.bytes().all(|b| b.is_ascii_hexdigit()) => {}
144 _ => return false,
145 }
146 }
147 groups.next().is_none()
148}
149
150pub fn is_alpha(value: &str) -> bool {
152 !value.is_empty() && value.chars().all(char::is_alphabetic)
153}
154
155pub fn is_alpha_num(value: &str) -> bool {
157 !value.is_empty() && value.chars().all(char::is_alphanumeric)
158}
159
160pub fn is_alpha_dash(value: &str) -> bool {
162 !value.is_empty() && value.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn accepts_ordinary_email_addresses() {
171 for address in ["ada@example.com", "a.b+tag@sub.example.co.uk", "x_1@a-b.io"] {
172 assert!(is_email(address), "{address} should be a valid email");
173 }
174 }
175
176 #[test]
177 fn rejects_email_addresses_people_actually_mistype() {
178 for address in [
179 "",
180 "ada",
181 "ada@",
182 "@example.com",
183 "ada@example",
184 "ada@@example.com",
185 "ada b@example.com",
186 "ada@example..com",
187 ".ada@example.com",
188 "ada.@example.com",
189 "ada@-example.com",
190 "ada@example.c",
191 "ada@example.c0m",
192 ] {
193 assert!(!is_email(address), "{address} should not be a valid email");
194 }
195 }
196
197 #[test]
198 fn an_over_long_address_is_rejected_without_scanning_it() {
199 let address = format!("{}@example.com", "a".repeat(300));
200 assert!(!is_email(&address));
201 }
202
203 #[test]
204 fn accepts_urls_with_a_scheme_and_a_host() {
205 for url in [
206 "https://example.com",
207 "http://example.com/path?q=1#top",
208 "https://user:pw@example.com:8443/x",
209 "ftp://files.example.org",
210 "http://localhost:3000",
211 "http://[::1]:8080/",
212 ] {
213 assert!(is_url(url), "{url} should be a valid url");
214 }
215 }
216
217 #[test]
218 fn rejects_urls_without_a_scheme_or_host() {
219 for url in ["example.com", "https://", "://example.com", "1http://x.com", "http://ex ample.com"] {
220 assert!(!is_url(url), "{url} should not be a valid url");
221 }
222 }
223
224 #[test]
225 fn accepts_real_calendar_dates_and_rejects_impossible_ones() {
226 assert!(is_date("2024-02-29"));
227 assert!(is_date("1999-12-31"));
228
229 assert!(!is_date("2023-02-29"), "2023 is not a leap year");
230 assert!(!is_date("1900-02-29"), "a century that is not divisible by 400 is not a leap year");
231 assert!(!is_date("2024-13-01"));
232 assert!(!is_date("2024-04-31"));
233 assert!(!is_date("2024-1-1"), "the format is zero padded");
234 assert!(!is_date("24-01-01"));
235 assert!(!is_date("2024/01/01"));
236 assert!(!is_date("+024-01-01"));
237 }
238
239 #[test]
240 fn recognises_uuids_of_any_version() {
241 assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
242 assert!(is_uuid("9f8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6c"));
243 assert!(is_uuid("9F8B2C1A-4D3E-4F5A-8B7C-1D2E3F4A5B6C"));
244
245 assert!(!is_uuid("9f8b2c1a4d3e4f5a8b7c1d2e3f4a5b6c"));
246 assert!(!is_uuid("9f8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6"));
247 assert!(!is_uuid("9f8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6c-extra"));
248 assert!(!is_uuid("zf8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6c"));
249 }
250
251 #[test]
252 fn alpha_families_agree_on_the_empty_string() {
253 assert!(!is_alpha(""));
254 assert!(!is_alpha_num(""));
255 assert!(!is_alpha_dash(""));
256 }
257
258 #[test]
259 fn alpha_families_widen_one_character_class_at_a_time() {
260 assert!(is_alpha("Zoë"));
261 assert!(!is_alpha("Zoe2"));
262
263 assert!(is_alpha_num("Zoe2"));
264 assert!(!is_alpha_num("zoe-2"));
265
266 assert!(is_alpha_dash("zoe-2_x"));
267 assert!(!is_alpha_dash("zoe 2"));
268 }
269}