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}