#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Uuid,
Email,
Hostname,
Ipv4,
Ipv6,
}
impl Format {
pub fn name(self) -> &'static str {
match self {
Format::Uuid => "uuid",
Format::Email => "email",
Format::Hostname => "hostname",
Format::Ipv4 => "ipv4",
Format::Ipv6 => "ipv6",
}
}
pub fn parse(name: &str) -> Option<Format> {
match name {
"uuid" => Some(Format::Uuid),
"email" => Some(Format::Email),
"hostname" => Some(Format::Hostname),
"ipv4" => Some(Format::Ipv4),
"ipv6" => Some(Format::Ipv6),
_ => None,
}
}
pub const ALL: [Format; 5] = [
Format::Uuid,
Format::Email,
Format::Hostname,
Format::Ipv4,
Format::Ipv6,
];
pub fn matches(self, value: &str) -> bool {
match self {
Format::Uuid => uuid(value),
Format::Email => email(value),
Format::Hostname => hostname(value),
Format::Ipv4 => ipv4(value),
Format::Ipv6 => ipv6(value),
}
}
}
fn uuid(v: &str) -> bool {
let groups = [8, 4, 4, 4, 12];
let mut parts = v.split('-');
for len in groups {
match parts.next() {
Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_hexdigit()) => {}
_ => return false,
}
}
parts.next().is_none()
}
fn email(v: &str) -> bool {
let Some(at) = v.rfind('@') else {
return false;
};
let (local, domain) = (&v[..at], &v[at + 1..]);
if local.is_empty() || local.len() > 64 {
return false;
}
if local
.bytes()
.any(|b| b <= b' ' || b == b'"' || b == b'\\' || b == b'@' || b == 0x7f)
{
return false;
}
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
return false;
}
hostname(domain) && domain.contains('.')
}
fn hostname(v: &str) -> bool {
if v.is_empty() || v.len() > 253 {
return false;
}
v.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
fn ipv4(v: &str) -> bool {
let mut octets = 0;
for part in v.split('.') {
octets += 1;
if octets > 4 || part.is_empty() || part.len() > 3 {
return false;
}
if !part.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
if part.len() > 1 && part.starts_with('0') {
return false;
}
if part.parse::<u16>().map_or(true, |n| n > 255) {
return false;
}
}
octets == 4
}
fn ipv6(v: &str) -> bool {
if v.contains('%') {
return false;
}
let halves: Vec<&str> = v.split("::").collect();
let (head, tail, elided) = match halves.as_slice() {
[whole] => (*whole, "", false),
[before, after] => (*before, *after, true),
_ => return false,
};
let groups = |s: &str| -> Option<(usize, bool)> {
if s.is_empty() {
return Some((0, false));
}
let parts: Vec<&str> = s.split(':').collect();
let mut count = 0;
let mut trailing_v4 = false;
for (i, part) in parts.iter().enumerate() {
let last = i + 1 == parts.len();
if last && part.contains('.') {
if !ipv4(part) {
return None;
}
count += 2;
trailing_v4 = true;
continue;
}
if part.is_empty() || part.len() > 4 || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
count += 1;
}
Some((count, trailing_v4))
};
let Some((left, left_v4)) = groups(head) else {
return false;
};
let Some((right, right_v4)) = groups(tail) else {
return false;
};
if left_v4 && (elided || !tail.is_empty()) {
return false;
}
let total = left + right;
if elided {
total < 8
} else {
total == 8 && !right_v4
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuids() {
assert!(Format::Uuid.matches("6ba7b810-9dad-11d1-80b4-00c04fd430c8"));
assert!(Format::Uuid.matches("6BA7B810-9DAD-11D1-80B4-00C04FD430C8"));
assert!(Format::Uuid.matches("00000000-0000-0000-0000-000000000000"));
assert!(!Format::Uuid.matches("6ba7b810-9dad-11d1-80b4-00c04fd430c"));
assert!(!Format::Uuid.matches("6ba7b8109dad11d180b400c04fd430c8"));
assert!(!Format::Uuid.matches("6ba7b810-9dad-11d1-80b4-00c04fd430c8-"));
assert!(!Format::Uuid.matches("gba7b810-9dad-11d1-80b4-00c04fd430c8"));
assert!(!Format::Uuid.matches(""));
}
#[test]
fn emails() {
assert!(Format::Email.matches("gabriel@example.com"));
assert!(Format::Email.matches("first.last+tag@sub.example.co.uk"));
assert!(Format::Email.matches("a@b.co"));
assert!(!Format::Email.matches("no-at-sign.example.com"));
assert!(!Format::Email.matches("@example.com"));
assert!(!Format::Email.matches("user@"));
assert!(!Format::Email.matches("user@localhost"));
assert!(!Format::Email.matches("user name@example.com"));
assert!(!Format::Email.matches(".user@example.com"));
assert!(!Format::Email.matches("user..name@example.com"));
}
#[test]
fn hostnames() {
assert!(Format::Hostname.matches("example.com"));
assert!(Format::Hostname.matches("localhost"));
assert!(Format::Hostname.matches("a-b.example.com"));
assert!(!Format::Hostname.matches(""));
assert!(!Format::Hostname.matches("-example.com"));
assert!(!Format::Hostname.matches("example-.com"));
assert!(!Format::Hostname.matches("exa mple.com"));
assert!(!Format::Hostname.matches("example..com"));
}
#[test]
fn ipv4_addresses() {
assert!(Format::Ipv4.matches("192.168.0.1"));
assert!(Format::Ipv4.matches("0.0.0.0"));
assert!(Format::Ipv4.matches("255.255.255.255"));
assert!(!Format::Ipv4.matches("256.0.0.1"));
assert!(!Format::Ipv4.matches("1.2.3"));
assert!(!Format::Ipv4.matches("1.2.3.4.5"));
assert!(!Format::Ipv4.matches("010.0.0.1"));
assert!(!Format::Ipv4.matches("1.2.3.-4"));
}
#[test]
fn ipv6_addresses() {
assert!(Format::Ipv6.matches("2001:0db8:85a3:0000:0000:8a2e:0370:7334"));
assert!(Format::Ipv6.matches("2001:db8:85a3::8a2e:370:7334"));
assert!(Format::Ipv6.matches("::1"));
assert!(Format::Ipv6.matches("::"));
assert!(Format::Ipv6.matches("::ffff:192.168.0.1"));
assert!(!Format::Ipv6.matches("2001:db8::85a3::7334"));
assert!(!Format::Ipv6.matches("2001:db8:85a3:0:0:8a2e:370"));
assert!(!Format::Ipv6.matches("gggg::1"));
assert!(!Format::Ipv6.matches("fe80::1%eth0"));
assert!(!Format::Ipv6.matches(""));
}
#[test]
fn names_round_trip() {
for f in Format::ALL {
assert_eq!(Format::parse(f.name()), Some(f));
}
assert_eq!(Format::parse("regex"), None);
}
}