Skip to main content

seam_core/
format.rs

1//! Named string formats, checked by hand.
2//!
3//! There is no regular expression here, and that is the design rather than an
4//! omission. A general `@pattern(...)` would need a regex engine: a
5//! backtracking one lets a hostile schema or a hostile payload burn unbounded
6//! time, which breaks the promise that input is bounded, and a linear one
7//! means the engine's first dependency plus its weight in every host — a crate
8//! larger than the whole of `seam-core`, carried into a browser bundle that is
9//! currently 113 KiB.
10//!
11//! A closed set of names costs neither. It also says something a pattern
12//! cannot: `@format(uuid)` states what the value *is*, while a regex states
13//! what it looks like, and only the first survives someone tightening the
14//! pattern later.
15//!
16//! Each check below documents what it does **not** enforce. A format that
17//! quietly rejects legitimate values is worse than no format at all, because
18//! the failure lands on a user who is holding a perfectly good address.
19
20/// A named format a string must satisfy.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Format {
23    /// `8-4-4-4-12` lowercase or uppercase hex. Any version, any variant:
24    /// version bits are a fact about who generated it, not about whether the
25    /// value is a UUID.
26    Uuid,
27    /// A structural check only: one `@`, a non-empty local part, and a domain
28    /// that looks like a hostname.
29    ///
30    /// Deliberately not RFC 5322, which admits comments, quoted strings and
31    /// nested parentheses that no mail system in use accepts. Deliberately not
32    /// a deliverability check either — that needs the network, and a validator
33    /// that reached the network at a boundary would be a much worse idea than
34    /// a permissive check.
35    Email,
36    /// A DNS hostname per RFC 1123: dot-separated labels of letters, digits
37    /// and hyphens, each 1 to 63 characters, not starting or ending with a
38    /// hyphen, 253 characters in total.
39    Hostname,
40    /// Four decimal octets. Leading zeros are rejected, because `010` is octal
41    /// to some resolvers and decimal to others, and a value that means two
42    /// different things on two hosts is exactly what this project exists to
43    /// refuse.
44    Ipv4,
45    /// An IPv6 address, including the `::` short form and a trailing IPv4
46    /// part. Zone identifiers (`%eth0`) are rejected: they name an interface
47    /// on one machine and mean nothing on another.
48    Ipv6,
49}
50
51impl Format {
52    pub fn name(self) -> &'static str {
53        match self {
54            Format::Uuid => "uuid",
55            Format::Email => "email",
56            Format::Hostname => "hostname",
57            Format::Ipv4 => "ipv4",
58            Format::Ipv6 => "ipv6",
59        }
60    }
61
62    pub fn parse(name: &str) -> Option<Format> {
63        match name {
64            "uuid" => Some(Format::Uuid),
65            "email" => Some(Format::Email),
66            "hostname" => Some(Format::Hostname),
67            "ipv4" => Some(Format::Ipv4),
68            "ipv6" => Some(Format::Ipv6),
69            _ => None,
70        }
71    }
72
73    /// Every name, in declaration order, for an error message that tells the
74    /// author what they could have written instead.
75    pub const ALL: [Format; 5] = [
76        Format::Uuid,
77        Format::Email,
78        Format::Hostname,
79        Format::Ipv4,
80        Format::Ipv6,
81    ];
82
83    pub fn matches(self, value: &str) -> bool {
84        match self {
85            Format::Uuid => uuid(value),
86            Format::Email => email(value),
87            Format::Hostname => hostname(value),
88            Format::Ipv4 => ipv4(value),
89            Format::Ipv6 => ipv6(value),
90        }
91    }
92}
93
94fn uuid(v: &str) -> bool {
95    let groups = [8, 4, 4, 4, 12];
96    let mut parts = v.split('-');
97    for len in groups {
98        match parts.next() {
99            Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_hexdigit()) => {}
100            _ => return false,
101        }
102    }
103    parts.next().is_none()
104}
105
106fn email(v: &str) -> bool {
107    // Split at the last `@`: a local part may contain one, a domain may not.
108    let Some(at) = v.rfind('@') else {
109        return false;
110    };
111    let (local, domain) = (&v[..at], &v[at + 1..]);
112
113    if local.is_empty() || local.len() > 64 {
114        return false;
115    }
116    // No control characters, no spaces, and nothing that would need quoting.
117    // A quoted local part is legal and essentially unused; rejecting it is a
118    // documented limit rather than an accident.
119    if local
120        .bytes()
121        .any(|b| b <= b' ' || b == b'"' || b == b'\\' || b == b'@' || b == 0x7f)
122    {
123        return false;
124    }
125    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
126        return false;
127    }
128    // A domain without a dot is syntactically fine and never routable from
129    // outside its own network, which at an API boundary is a typo every time.
130    hostname(domain) && domain.contains('.')
131}
132
133fn hostname(v: &str) -> bool {
134    if v.is_empty() || v.len() > 253 {
135        return false;
136    }
137    v.split('.').all(|label| {
138        !label.is_empty()
139            && label.len() <= 63
140            && !label.starts_with('-')
141            && !label.ends_with('-')
142            && label
143                .bytes()
144                .all(|b| b.is_ascii_alphanumeric() || b == b'-')
145    })
146}
147
148fn ipv4(v: &str) -> bool {
149    let mut octets = 0;
150    for part in v.split('.') {
151        octets += 1;
152        if octets > 4 || part.is_empty() || part.len() > 3 {
153            return false;
154        }
155        if !part.bytes().all(|b| b.is_ascii_digit()) {
156            return false;
157        }
158        // `010` is octal to some resolvers and decimal to others.
159        if part.len() > 1 && part.starts_with('0') {
160            return false;
161        }
162        if part.parse::<u16>().map_or(true, |n| n > 255) {
163            return false;
164        }
165    }
166    octets == 4
167}
168
169fn ipv6(v: &str) -> bool {
170    // A zone identifier names an interface on one machine and nothing on
171    // another, so it is not part of a portable address.
172    if v.contains('%') {
173        return false;
174    }
175
176    // At most one `::`, which stands for one or more groups of zeros.
177    let halves: Vec<&str> = v.split("::").collect();
178    let (head, tail, elided) = match halves.as_slice() {
179        [whole] => (*whole, "", false),
180        [before, after] => (*before, *after, true),
181        _ => return false,
182    };
183
184    let groups = |s: &str| -> Option<(usize, bool)> {
185        if s.is_empty() {
186            return Some((0, false));
187        }
188        let parts: Vec<&str> = s.split(':').collect();
189        let mut count = 0;
190        let mut trailing_v4 = false;
191        for (i, part) in parts.iter().enumerate() {
192            let last = i + 1 == parts.len();
193            // The final group may be a dotted IPv4 address, which occupies two.
194            if last && part.contains('.') {
195                if !ipv4(part) {
196                    return None;
197                }
198                count += 2;
199                trailing_v4 = true;
200                continue;
201            }
202            if part.is_empty() || part.len() > 4 || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
203                return None;
204            }
205            count += 1;
206        }
207        Some((count, trailing_v4))
208    };
209
210    let Some((left, left_v4)) = groups(head) else {
211        return false;
212    };
213    let Some((right, right_v4)) = groups(tail) else {
214        return false;
215    };
216    // An embedded IPv4 part only ever ends the address.
217    if left_v4 && (elided || !tail.is_empty()) {
218        return false;
219    }
220    let total = left + right;
221
222    if elided {
223        // `::` must stand for at least one group, or it would be a plain `:`.
224        total < 8
225    } else {
226        total == 8 && !right_v4
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn uuids() {
236        assert!(Format::Uuid.matches("6ba7b810-9dad-11d1-80b4-00c04fd430c8"));
237        assert!(Format::Uuid.matches("6BA7B810-9DAD-11D1-80B4-00C04FD430C8"));
238        // Any version and variant: those bits say who made it, not whether it
239        // is one.
240        assert!(Format::Uuid.matches("00000000-0000-0000-0000-000000000000"));
241
242        assert!(!Format::Uuid.matches("6ba7b810-9dad-11d1-80b4-00c04fd430c"));
243        assert!(!Format::Uuid.matches("6ba7b8109dad11d180b400c04fd430c8"));
244        assert!(!Format::Uuid.matches("6ba7b810-9dad-11d1-80b4-00c04fd430c8-"));
245        assert!(!Format::Uuid.matches("gba7b810-9dad-11d1-80b4-00c04fd430c8"));
246        assert!(!Format::Uuid.matches(""));
247    }
248
249    #[test]
250    fn emails() {
251        assert!(Format::Email.matches("gabriel@example.com"));
252        assert!(Format::Email.matches("first.last+tag@sub.example.co.uk"));
253        assert!(Format::Email.matches("a@b.co"));
254
255        assert!(!Format::Email.matches("no-at-sign.example.com"));
256        assert!(!Format::Email.matches("@example.com"));
257        assert!(!Format::Email.matches("user@"));
258        assert!(!Format::Email.matches("user@localhost"));
259        assert!(!Format::Email.matches("user name@example.com"));
260        assert!(!Format::Email.matches(".user@example.com"));
261        assert!(!Format::Email.matches("user..name@example.com"));
262    }
263
264    #[test]
265    fn hostnames() {
266        assert!(Format::Hostname.matches("example.com"));
267        assert!(Format::Hostname.matches("localhost"));
268        assert!(Format::Hostname.matches("a-b.example.com"));
269
270        assert!(!Format::Hostname.matches(""));
271        assert!(!Format::Hostname.matches("-example.com"));
272        assert!(!Format::Hostname.matches("example-.com"));
273        assert!(!Format::Hostname.matches("exa mple.com"));
274        assert!(!Format::Hostname.matches("example..com"));
275    }
276
277    #[test]
278    fn ipv4_addresses() {
279        assert!(Format::Ipv4.matches("192.168.0.1"));
280        assert!(Format::Ipv4.matches("0.0.0.0"));
281        assert!(Format::Ipv4.matches("255.255.255.255"));
282
283        assert!(!Format::Ipv4.matches("256.0.0.1"));
284        assert!(!Format::Ipv4.matches("1.2.3"));
285        assert!(!Format::Ipv4.matches("1.2.3.4.5"));
286        // Octal to some resolvers, decimal to others.
287        assert!(!Format::Ipv4.matches("010.0.0.1"));
288        assert!(!Format::Ipv4.matches("1.2.3.-4"));
289    }
290
291    #[test]
292    fn ipv6_addresses() {
293        assert!(Format::Ipv6.matches("2001:0db8:85a3:0000:0000:8a2e:0370:7334"));
294        assert!(Format::Ipv6.matches("2001:db8:85a3::8a2e:370:7334"));
295        assert!(Format::Ipv6.matches("::1"));
296        assert!(Format::Ipv6.matches("::"));
297        assert!(Format::Ipv6.matches("::ffff:192.168.0.1"));
298
299        assert!(!Format::Ipv6.matches("2001:db8::85a3::7334"));
300        assert!(!Format::Ipv6.matches("2001:db8:85a3:0:0:8a2e:370"));
301        assert!(!Format::Ipv6.matches("gggg::1"));
302        // A zone identifier names an interface on one machine only.
303        assert!(!Format::Ipv6.matches("fe80::1%eth0"));
304        assert!(!Format::Ipv6.matches(""));
305    }
306
307    #[test]
308    fn names_round_trip() {
309        for f in Format::ALL {
310            assert_eq!(Format::parse(f.name()), Some(f));
311        }
312        assert_eq!(Format::parse("regex"), None);
313    }
314}