Skip to main content

shape/
format.rs

1//! String formats: Email, Url, Uuid, DateTime, Ip, Ipv4, Ipv6. Every pattern
2//! is written so that the `regex` crate and the JavaScript engine agree on
3//! it: ASCII classes only, no lookaround, explicit whitespace.
4
5use crate::context::{State, Update, UpdateErr};
6use crate::node::{Kind, Node, Validator};
7use crate::value::Value;
8use regex::Regex;
9use std::sync::{Arc, OnceLock};
10
11fn re(cell: &'static OnceLock<Regex>, src: &str) -> &'static Regex {
12    cell.get_or_init(|| Regex::new(src).unwrap())
13}
14
15/// A pragmatic RFC 5322 addr-spec: a dot-atom local part of at most 64
16/// characters, then a dotted domain ending in an alphabetic top-level label,
17/// 254 characters in all. No quoted local parts, no address literals.
18fn email_re() -> &'static Regex {
19    static RE: OnceLock<Regex> = OnceLock::new();
20    re(
21        &RE,
22        "^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*\
23         @(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}$",
24    )
25}
26
27pub(crate) fn is_email(s: &str) -> bool {
28    s.len() <= 254 && s.find('@').map(|i| i <= 64).unwrap_or(false) && email_re().is_match(s)
29}
30
31/// scheme://[user@]host[:port][/path][?query][#fragment]: an absolute URL
32/// with a non-empty host and no whitespace. Nothing is decoded or resolved.
33fn url_re() -> &'static Regex {
34    static RE: OnceLock<Regex> = OnceLock::new();
35    re(
36        &RE,
37        r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^ \t\r\n/?#@]+@)?(?:\[[0-9A-Fa-f:.]+\]|[^ \t\r\n/?#@:\[\]]+)(?::\d{1,5})?(?:[/?#][^ \t\r\n]*)?$",
38    )
39}
40
41/// 8-4-4-4-12 hex digits; any version, including the nil UUID.
42fn uuid_re() -> &'static Regex {
43    static RE: OnceLock<Regex> = OnceLock::new();
44    re(
45        &RE,
46        r"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
47    )
48}
49
50/// A dotted quad of decimal octets 0-255 without leading zeros.
51fn ipv4_re() -> &'static Regex {
52    static RE: OnceLock<Regex> = OnceLock::new();
53    re(
54        &RE,
55        r"^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$",
56    )
57}
58
59fn hex4_re() -> &'static Regex {
60    static RE: OnceLock<Regex> = OnceLock::new();
61    re(&RE, r"^[0-9A-Fa-f]{1,4}$")
62}
63
64pub(crate) fn is_ipv4(s: &str) -> bool {
65    ipv4_re().is_match(s)
66}
67
68/// The RFC 4291 text form: eight 16-bit hex groups, one optional `::`
69/// standing for a run of zero groups, and optionally a trailing dotted quad
70/// in place of the last two groups. No zone index and no prefix length.
71pub(crate) fn is_ipv6(s: &str) -> bool {
72    let parts: Vec<&str> = s.split("::").collect();
73    if parts.len() > 2 {
74        return false;
75    }
76    let head: Vec<&str> = if parts[0].is_empty() {
77        Vec::new()
78    } else {
79        parts[0].split(':').collect()
80    };
81    let tail: Vec<&str> = if parts.len() == 2 && !parts[1].is_empty() {
82        parts[1].split(':').collect()
83    } else {
84        Vec::new()
85    };
86    let groups: Vec<&str> = head.iter().chain(tail.iter()).copied().collect();
87
88    let mut count = 0;
89    for (i, g) in groups.iter().enumerate() {
90        if hex4_re().is_match(g) {
91            count += 1;
92        } else if i == groups.len() - 1 && (parts.len() == 1 || head.len() <= i) && is_ipv4(g) {
93            // A dotted quad may only end the address, so not ahead of a "::".
94            count += 2;
95        } else {
96            return false;
97        }
98    }
99    if parts.len() == 2 {
100        count <= 7
101    } else {
102        count == 8
103    }
104}
105
106/// One string format builder.
107#[derive(Clone, Copy)]
108pub(crate) struct Format {
109    /// The builder name, and the why code of its error.
110    pub name: &'static str,
111    /// The noun in "is not a valid <what>".
112    pub what: &'static str,
113    pub valid: fn(&str) -> bool,
114}
115
116pub(crate) const FMT_EMAIL: Format = Format {
117    name: "Email",
118    what: "email address",
119    valid: is_email,
120};
121pub(crate) const FMT_URL: Format = Format {
122    name: "Url",
123    what: "URL",
124    valid: |s| url_re().is_match(s),
125};
126pub(crate) const FMT_UUID: Format = Format {
127    name: "Uuid",
128    what: "UUID",
129    valid: |s| uuid_re().is_match(s),
130};
131pub(crate) const FMT_DATE_TIME: Format = Format {
132    name: "DateTime",
133    what: "ISO 8601 date-time",
134    valid: crate::coerce::is_iso_date_time,
135};
136pub(crate) const FMT_IP: Format = Format {
137    name: "Ip",
138    what: "IP address",
139    valid: |s| is_ipv4(s) || is_ipv6(s),
140};
141pub(crate) const FMT_IPV4: Format = Format {
142    name: "Ipv4",
143    what: "IPv4 address",
144    valid: is_ipv4,
145};
146pub(crate) const FMT_IPV6: Format = Format {
147    name: "Ipv6",
148    what: "IPv6 address",
149    valid: is_ipv6,
150};
151
152/// A format is a before on a string-shaped node. It speaks only once the
153/// value is known to be present and of the node's kind; otherwise the
154/// structural check reports the real problem.
155pub(crate) fn format_validator(f: Format) -> Validator {
156    Validator {
157        name: f.name.to_string(),
158        func: Arc::new(move |state: &mut State<'_>, update: &mut Update| {
159            if state.absent || crate::builders::type_will_fail(state.node, state.value) {
160                return true;
161            }
162            if let Value::Str(s) = state.value {
163                if (f.valid)(s) {
164                    return true;
165                }
166            }
167            update.err = Some(UpdateErr::Text(format!(
168                "Value \"$VALUE\" for property \"$PATH\" is not a valid {}.",
169                f.what
170            )));
171            update.why = Some(f.name.to_string());
172            update.mark = 0;
173            false
174        }),
175        args: Vec::new(),
176        suffix: Some(f.name.to_string()),
177        inner: None,
178    }
179}
180
181/// Add the format check. A format is a shape of string, so an untyped node
182/// becomes one first.
183pub(crate) fn with_format(mut n: Node, f: Format) -> Node {
184    if n.kind == Kind::Any {
185        n = crate::builders::type_(Kind::String, n);
186    }
187    n.befores.push(format_validator(f));
188    n
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn ipv6_forms() {
197        assert!(is_ipv6("::1"));
198        assert!(is_ipv6("2001:db8::8a2e:370:7334"));
199        assert!(is_ipv6("2001:0db8:0000:0000:0000:ff00:0042:8329"));
200        assert!(is_ipv6("::ffff:192.168.1.1"));
201        assert!(is_ipv6("64:ff9b::192.0.2.33"));
202        assert!(!is_ipv6("1:2:3:4:5:6:7:8:9"));
203        assert!(!is_ipv6("1::2::3"));
204        assert!(!is_ipv6("192.168.1.1::1"));
205        assert!(!is_ipv6("2001:db8::8a2e:370:7334:1:2:3"));
206        assert!(!is_ipv6("gggg::1"));
207        assert!(!is_ipv6("1:2:3:4:5:6:7"));
208        assert!(is_ipv6("1:2:3:4:5:6:1.2.3.4"));
209        assert!(!is_ipv6("1:2:3:4:5:6:7:1.2.3.4"));
210    }
211
212    #[test]
213    fn email_limits() {
214        assert!(is_email("a@b.co"));
215        assert!(!is_email("a"));
216        let long_local = format!("{}@b.co", "a".repeat(65));
217        assert!(!is_email(&long_local));
218        let long = format!("a@{}.co", "b".repeat(260));
219        assert!(!is_email(&long));
220    }
221}