Skip to main content

rustlavel_validation/
check.rs

1//! The hand-written format checks the rules are built on.
2//!
3//! Laravel leans on PCRE for `email`, `url`, `uuid` and friends. Rustlavel has
4//! no regex engine and does not want one, so each shape is a small function
5//! here instead. They are deliberately *pragmatic, not RFC-exhaustive*: the job
6//! is to reject the typos a user actually makes in a form, not to accept every
7//! address RFC 5322 permits. Each function documents where it draws that line.
8
9/// A practical email shape: `local@domain`, one `@`, a domain with a real TLD.
10///
11/// Rejected on purpose even though RFC 5322 allows them: quoted local parts
12/// (`"a b"@x.com`), comments, and bare hosts without a dot (`root@localhost`).
13/// Anyone typing an address into a form is not typing one of those, and every
14/// one of them is far more likely to be a mistake than an intent.
15pub fn is_email(value: &str) -> bool {
16    // The SMTP path limit; also stops a pathological input from being scanned.
17    if value.len() > 254 {
18        return false;
19    }
20    let Some((local, domain)) = value.split_once('@') else {
21        return false;
22    };
23    // A second `@` lands in `domain`, so checking there catches `a@b@c`.
24    !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
39/// A dotted host with at least two labels and an alphabetic TLD.
40fn 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
59/// A URL with an explicit scheme and a non-empty host: `https://example.com/x`.
60///
61/// The scheme is not restricted to http/https — `ftp://` and `redis://` are
62/// URLs too — but `://` is required, so a bare `example.com` is rejected. That
63/// is the mistake worth catching; a scheme allowlist belongs to the
64/// application, not to a generic `url` rule.
65pub 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    // The authority runs up to the first path, query, or fragment delimiter.
79    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
86/// Drop a `:8080` suffix, leaving a bracketed IPv6 literal intact.
87fn 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
97/// A calendar date in `YYYY-MM-DD`, the format an `<input type="date">` sends.
98///
99/// The day is checked against the month, leap years included, so `2023-02-30`
100/// fails rather than silently becoming March.
101pub 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
114/// Parse a run of ASCII digits. `str::parse` alone would accept `+12`.
115fn 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
133/// The `8-4-4-4-12` hex shape of a UUID.
134///
135/// The version and variant nibbles are not checked: a value that came out of
136/// another system may be a v1, v4, v7, or the nil UUID, and rejecting one of
137/// those as "not a UUID" would be wrong.
138pub 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
150/// Letters only. Unicode-aware, like Laravel's default: `Ada` and `Zoë` both pass.
151pub fn is_alpha(value: &str) -> bool {
152    !value.is_empty() && value.chars().all(char::is_alphabetic)
153}
154
155/// Letters and digits only.
156pub fn is_alpha_num(value: &str) -> bool {
157    !value.is_empty() && value.chars().all(char::is_alphanumeric)
158}
159
160/// Letters, digits, dashes and underscores — the shape of a URL slug.
161pub 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}