Skip to main content

aep_core/
claims.rs

1use std::{collections::HashSet, net::IpAddr, str::FromStr, sync::OnceLock};
2
3use regex::Regex;
4use time::{Date, Month};
5
6use crate::{
7    ClaimName, ClaimValues, InspectClaims, ParseError, ValidationError,
8    validation::{issue, parse_and_validate, result, validate_optional_non_empty},
9};
10
11#[derive(Clone, Debug, Default, Eq, PartialEq)]
12pub struct ClaimSupportEvaluation {
13    pub can_satisfy_required: bool,
14    pub supported_optional: Vec<ClaimName>,
15    pub supported_preferred: Vec<ClaimName>,
16    pub unsupported_required: Vec<ClaimName>,
17}
18
19pub fn parse_claim_values(data: &[u8]) -> Result<ClaimValues, ParseError> {
20    parse_and_validate(data, "Claim Values", validate_claim_values)
21}
22
23pub fn validate_claim_values(value: &ClaimValues) -> Result<(), ValidationError> {
24    let mut issues = Vec::new();
25    if let Some(address) = &value.contact_address_primary {
26        let path = "$.contact.address.primary";
27        if !country_pattern().is_match(&address.country) {
28            issues.push(issue(
29                format!("{path}.country"),
30                "Expected a two-letter uppercase country code.",
31            ));
32        }
33        validate_optional_non_empty(
34            Some(address.first_name.as_str()),
35            &format!("{path}.first_name"),
36            &mut issues,
37        );
38        validate_optional_non_empty(
39            Some(address.last_name.as_str()),
40            &format!("{path}.last_name"),
41            &mut issues,
42        );
43        validate_optional_non_empty(
44            Some(address.line1.as_str()),
45            &format!("{path}.line1"),
46            &mut issues,
47        );
48        validate_optional_non_empty(
49            address.city.as_deref(),
50            &format!("{path}.city"),
51            &mut issues,
52        );
53        if address.additional.contains_key("postal_code") {
54            issues.push(issue(
55                format!("{path}.postal_code"),
56                "Expected the postcode member.",
57            ));
58        }
59    }
60    if value
61        .contact_email
62        .as_deref()
63        .is_some_and(|email| email.len() < 3 || !is_email_mailbox(email))
64    {
65        issues.push(issue("$.contact.email", "Expected an RFC 5321 Mailbox."));
66    }
67    if value
68        .contact_mobile
69        .as_deref()
70        .is_some_and(|mobile| !e164_pattern().is_match(mobile))
71    {
72        issues.push(issue(
73            "$.contact.mobile",
74            "Expected an E.164 telephone number.",
75        ));
76    }
77    if value
78        .person_birthdate
79        .as_deref()
80        .is_some_and(|birthdate| !is_full_date(birthdate))
81    {
82        issues.push(issue(
83            "$.person.birthdate",
84            "Expected an RFC 3339 full-date.",
85        ));
86    }
87    validate_optional_non_empty(
88        value.person_first_name.as_deref(),
89        "$.person.first_name",
90        &mut issues,
91    );
92    validate_optional_non_empty(
93        value.person_last_name.as_deref(),
94        "$.person.last_name",
95        &mut issues,
96    );
97    validate_optional_non_empty(
98        value.person_username.as_deref(),
99        "$.person.username",
100        &mut issues,
101    );
102    result("Claim Values", issues)
103}
104
105pub fn evaluate_claim_support(
106    requested: Option<&InspectClaims>,
107    supported_claim_names: impl IntoIterator<Item = ClaimName>,
108) -> ClaimSupportEvaluation {
109    let supported = supported_claim_names.into_iter().collect::<HashSet<_>>();
110    let Some(requested) = requested else {
111        return ClaimSupportEvaluation {
112            can_satisfy_required: true,
113            ..ClaimSupportEvaluation::default()
114        };
115    };
116    let unsupported_required = requested
117        .required
118        .iter()
119        .filter(|name| !supported.contains(*name))
120        .cloned()
121        .collect::<Vec<_>>();
122    ClaimSupportEvaluation {
123        can_satisfy_required: unsupported_required.is_empty(),
124        supported_optional: requested
125            .optional
126            .iter()
127            .filter(|name| supported.contains(*name))
128            .cloned()
129            .collect(),
130        supported_preferred: requested
131            .preferred
132            .iter()
133            .filter(|name| supported.contains(*name))
134            .cloned()
135            .collect(),
136        unsupported_required,
137    }
138}
139
140pub fn missing_required_claim_names(
141    required: &[ClaimName],
142    values: Option<&ClaimValues>,
143) -> Vec<ClaimName> {
144    required
145        .iter()
146        .filter(|name| values.is_none_or(|values| !has_claim(values, name)))
147        .cloned()
148        .collect()
149}
150
151fn has_claim(values: &ClaimValues, name: &ClaimName) -> bool {
152    match name {
153        ClaimName::ContactAddressPrimary => values.contact_address_primary.is_some(),
154        ClaimName::ContactEmail => values.contact_email.is_some(),
155        ClaimName::ContactMobile => values.contact_mobile.is_some(),
156        ClaimName::PersonBirthdate => values.person_birthdate.is_some(),
157        ClaimName::PersonFirstName => values.person_first_name.is_some(),
158        ClaimName::PersonLastName => values.person_last_name.is_some(),
159        ClaimName::PersonUsername => values.person_username.is_some(),
160        ClaimName::Other(name) => values.additional.contains_key(name),
161    }
162}
163
164fn e164_pattern() -> &'static Regex {
165    static PATTERN: OnceLock<Regex> = OnceLock::new();
166    PATTERN.get_or_init(|| Regex::new(r"^\+[1-9][0-9]{1,14}$").expect("valid E.164 pattern"))
167}
168
169fn country_pattern() -> &'static Regex {
170    static PATTERN: OnceLock<Regex> = OnceLock::new();
171    PATTERN.get_or_init(|| Regex::new(r"^[A-Z]{2}$").expect("valid country pattern"))
172}
173
174fn atom_pattern() -> &'static Regex {
175    static PATTERN: OnceLock<Regex> = OnceLock::new();
176    PATTERN.get_or_init(|| {
177        Regex::new(r"^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+$").expect("valid mailbox atom pattern")
178    })
179}
180
181fn domain_label_pattern() -> &'static Regex {
182    static PATTERN: OnceLock<Regex> = OnceLock::new();
183    PATTERN.get_or_init(|| {
184        Regex::new(r"^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$")
185            .expect("valid domain label pattern")
186    })
187}
188
189fn is_email_mailbox(value: &str) -> bool {
190    let Some(separator) = mailbox_separator(value) else {
191        return false;
192    };
193    if separator == 0 || separator == value.len() - 1 {
194        return false;
195    }
196    let (local, domain_with_at) = value.split_at(separator);
197    let domain = &domain_with_at[1..];
198    local.len() <= 64 && domain.len() <= 255 && is_local_part(local) && is_mailbox_domain(domain)
199}
200
201fn mailbox_separator(value: &str) -> Option<usize> {
202    if !value.starts_with('"') {
203        let separator = value.find('@')?;
204        return (value.rfind('@') == Some(separator)).then_some(separator);
205    }
206    let bytes = value.as_bytes();
207    let mut escaped = false;
208    for index in 1..bytes.len() {
209        if escaped {
210            escaped = false;
211        } else if bytes[index] == b'\\' {
212            escaped = true;
213        } else if bytes[index] == b'"' {
214            return (bytes.get(index + 1) == Some(&b'@')).then_some(index + 1);
215        }
216    }
217    None
218}
219
220fn is_local_part(value: &str) -> bool {
221    if value.starts_with('"') {
222        return is_quoted_local_part(value);
223    }
224    value.split('.').all(|atom| atom_pattern().is_match(atom))
225}
226
227fn is_quoted_local_part(value: &str) -> bool {
228    let bytes = value.as_bytes();
229    if bytes.len() < 2 || bytes.last() != Some(&b'"') {
230        return false;
231    }
232    let mut index = 1;
233    while index < bytes.len() - 1 {
234        let byte = bytes[index];
235        if byte == b'\\' {
236            index += 1;
237            if index >= bytes.len() - 1 || !(32..=126).contains(&bytes[index]) {
238                return false;
239            }
240        } else if !((32..=33).contains(&byte)
241            || (35..=91).contains(&byte)
242            || (93..=126).contains(&byte))
243        {
244            return false;
245        }
246        index += 1;
247    }
248    true
249}
250
251fn is_mailbox_domain(value: &str) -> bool {
252    if value.starts_with('[') || value.ends_with(']') {
253        return is_address_literal(value);
254    }
255    value
256        .split('.')
257        .all(|label| label.len() <= 63 && domain_label_pattern().is_match(label))
258}
259
260fn is_address_literal(value: &str) -> bool {
261    let Some(content) = value
262        .strip_prefix('[')
263        .and_then(|value| value.strip_suffix(']'))
264    else {
265        return false;
266    };
267    if content.contains('.') && IpAddr::from_str(content).is_ok() {
268        return true;
269    }
270    if let Some(ipv6) = content.strip_prefix("IPv6:") {
271        return IpAddr::from_str(ipv6).is_ok();
272    }
273    let Some((tag, literal)) = content.split_once(':') else {
274        return false;
275    };
276    !tag.is_empty()
277        && !literal.is_empty()
278        && tag.bytes().enumerate().all(|(index, byte)| {
279            byte.is_ascii_alphanumeric() || (byte == b'-' && index + 1 < tag.len())
280        })
281        && literal
282            .bytes()
283            .all(|byte| (33..=90).contains(&byte) || (94..=126).contains(&byte))
284}
285
286fn is_full_date(value: &str) -> bool {
287    if value.len() != 10 || value.as_bytes()[4] != b'-' || value.as_bytes()[7] != b'-' {
288        return false;
289    }
290    let Ok(year) = value[0..4].parse::<i32>() else {
291        return false;
292    };
293    let Ok(month_number) = value[5..7].parse::<u8>() else {
294        return false;
295    };
296    let Ok(month) = Month::try_from(month_number) else {
297        return false;
298    };
299    let Ok(day) = value[8..10].parse::<u8>() else {
300        return false;
301    };
302    Date::from_calendar_date(year, month, day).is_ok()
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn validates_registered_claim_shapes() {
311        let claims = parse_claim_values(
312            br#"{
313                "contact.address.primary": {
314                    "country": "US",
315                    "first_name": "Ada",
316                    "last_name": "Lovelace",
317                    "line1": "1 Example Way"
318                },
319                "contact.email": "ada@example.com",
320                "contact.mobile": "+14155550100",
321                "person.birthdate": "1815-12-10"
322            }"#,
323        )
324        .expect("valid claims");
325        assert_eq!(claims.contact_email.as_deref(), Some("ada@example.com"));
326    }
327
328    #[test]
329    fn rejects_legacy_postal_code() {
330        let error = parse_claim_values(
331            br#"{"contact.address.primary":{"country":"US","first_name":"Ada","last_name":"Lovelace","line1":"1 Example Way","postal_code":"12345"}}"#,
332        )
333        .expect_err("legacy member must fail");
334        assert!(matches!(error, ParseError::Validation(_)));
335    }
336
337    #[test]
338    fn rejects_invalid_registered_claim_shapes() {
339        for document in [
340            r#"{"contact.email":"not-an-address"}"#,
341            r#"{"contact.mobile":"4155550100"}"#,
342            r#"{"person.birthdate":"2025-02-29"}"#,
343            r#"{"person.first_name":""}"#,
344            r#"{"contact.address.primary":{"country":"usa","first_name":"Ada","last_name":"Lovelace","line1":"1 Way"}}"#,
345        ] {
346            assert!(
347                parse_claim_values(document.as_bytes()).is_err(),
348                "accepted {document}"
349            );
350        }
351    }
352
353    #[test]
354    fn evaluates_supported_and_missing_claims() {
355        let requested = InspectClaims {
356            required: vec![ClaimName::ContactEmail, ClaimName::ContactMobile],
357            preferred: vec![ClaimName::PersonFirstName],
358            optional: vec![ClaimName::PersonUsername],
359            additional: Default::default(),
360        };
361        let evaluation = evaluate_claim_support(
362            Some(&requested),
363            [ClaimName::ContactEmail, ClaimName::PersonFirstName],
364        );
365        assert!(!evaluation.can_satisfy_required);
366        assert_eq!(
367            evaluation.unsupported_required,
368            vec![ClaimName::ContactMobile]
369        );
370        assert_eq!(
371            evaluation.supported_preferred,
372            vec![ClaimName::PersonFirstName]
373        );
374        assert_eq!(
375            missing_required_claim_names(
376                &requested.required,
377                Some(&ClaimValues {
378                    contact_email: Some("ada@example.com".to_owned()),
379                    ..ClaimValues::default()
380                })
381            ),
382            vec![ClaimName::ContactMobile]
383        );
384        assert!(evaluate_claim_support(None, []).can_satisfy_required);
385    }
386}