agent_first_data/
validation.rs1pub fn normalize_utc_offset(s: &str) -> Option<String> {
7 let s = s.trim();
8 if s.eq_ignore_ascii_case("utc") || s.eq_ignore_ascii_case("z") {
9 return Some("UTC".to_string());
10 }
11 let sign = match s.as_bytes().first()? {
12 b'+' => '+',
13 b'-' => '-',
14 _ => return None,
15 };
16 let body = &s[1..];
17 let (hours, minutes) = parse_utc_offset_body(body)?;
18 if hours > 23 || minutes > 59 {
19 return None;
20 }
21 if hours == 0 && minutes == 0 {
22 return Some("UTC".to_string());
23 }
24 Some(format!("{sign}{hours:02}:{minutes:02}"))
25}
26
27pub fn is_valid_rfc3339_date(s: &str) -> bool {
29 let bytes = s.as_bytes();
30 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
31 return false;
32 }
33 let Some(year) = parse_ascii_u16_bytes(&bytes[0..4]) else {
34 return false;
35 };
36 let Some(month) = parse_ascii_u8_bytes(&bytes[5..7]) else {
37 return false;
38 };
39 let Some(day) = parse_ascii_u8_bytes(&bytes[8..10]) else {
40 return false;
41 };
42 (1..=12).contains(&month) && (1..=days_in_month(year, month)).contains(&day)
43}
44
45pub fn is_valid_rfc3339_time(s: &str) -> bool {
50 let bytes = s.as_bytes();
51 if bytes.len() < 8 || bytes[2] != b':' || bytes[5] != b':' {
52 return false;
53 }
54 let Some(hour) = parse_ascii_u8_bytes(&bytes[0..2]) else {
55 return false;
56 };
57 let Some(minute) = parse_ascii_u8_bytes(&bytes[3..5]) else {
58 return false;
59 };
60 let Some(second) = parse_ascii_u8_bytes(&bytes[6..8]) else {
61 return false;
62 };
63 if hour > 23 || minute > 59 || second > 59 {
64 return false;
65 }
66 if bytes.len() == 8 {
67 return true;
68 }
69 bytes[8] == b'.' && bytes.len() > 9 && bytes[9..].iter().all(u8::is_ascii_digit)
70}
71
72pub fn is_valid_rfc3339(s: &str) -> bool {
83 if !s.is_ascii() || s.len() < 20 {
84 return false;
85 }
86 if !is_valid_rfc3339_date(&s[0..10]) {
87 return false;
88 }
89 let bytes = s.as_bytes();
90 if bytes[10] != b'T' && bytes[10] != b't' {
91 return false;
92 }
93 let rest = &s[11..];
94 let last = rest.as_bytes()[rest.len() - 1];
95 let partial = if last == b'Z' || last == b'z' {
96 &rest[..rest.len() - 1]
97 } else {
98 if rest.len() < 6 {
99 return false;
100 }
101 if !is_rfc3339_numoffset(&rest[rest.len() - 6..]) {
102 return false;
103 }
104 &rest[..rest.len() - 6]
105 };
106 is_valid_rfc3339_time(partial)
107}
108
109fn is_rfc3339_numoffset(o: &str) -> bool {
111 let b = o.as_bytes();
112 if b.len() != 6 || (b[0] != b'+' && b[0] != b'-') || b[3] != b':' {
113 return false;
114 }
115 let (Some(hours), Some(minutes)) = (
116 parse_ascii_u8_bytes(&b[1..3]),
117 parse_ascii_u8_bytes(&b[4..6]),
118 ) else {
119 return false;
120 };
121 hours <= 23 && minutes <= 59
122}
123
124pub fn is_valid_bcp47(s: &str) -> bool {
133 if s.is_empty() {
134 return false;
135 }
136 for (index, subtag) in s.split('-').enumerate() {
137 let len = subtag.len();
138 if len == 0 || len > 8 || !subtag.bytes().all(|b| b.is_ascii_alphanumeric()) {
139 return false;
140 }
141 if index == 0 {
142 let is_language =
143 (2..=3).contains(&len) && subtag.bytes().all(|b| b.is_ascii_alphabetic());
144 let is_special = subtag == "x" || subtag == "i";
145 if !is_language && !is_special {
146 return false;
147 }
148 }
149 }
150 true
151}
152
153fn parse_utc_offset_body(body: &str) -> Option<(u8, u8)> {
154 if body.is_empty() {
155 return None;
156 }
157 if let Some((hours, minutes)) = body.split_once(':') {
158 if hours.is_empty() || hours.len() > 2 || minutes.len() != 2 {
159 return None;
160 }
161 return Some((parse_ascii_u8(hours)?, parse_ascii_u8(minutes)?));
162 }
163 if !body.bytes().all(|b| b.is_ascii_digit()) {
164 return None;
165 }
166 match body.len() {
167 1 | 2 => Some((parse_ascii_u8(body)?, 0)),
168 4 => Some((parse_ascii_u8(&body[..2])?, parse_ascii_u8(&body[2..])?)),
169 _ => None,
170 }
171}
172
173fn parse_ascii_u8(s: &str) -> Option<u8> {
174 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
175 return None;
176 }
177 s.parse().ok()
178}
179
180fn parse_ascii_u8_bytes(bytes: &[u8]) -> Option<u8> {
181 let n = parse_ascii_u16_bytes(bytes)?;
182 u8::try_from(n).ok()
183}
184
185fn parse_ascii_u16_bytes(bytes: &[u8]) -> Option<u16> {
186 if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) {
187 return None;
188 }
189 let mut value = 0u16;
190 for byte in bytes {
191 value = value.checked_mul(10)?;
192 value = value.checked_add(u16::from(byte - b'0'))?;
193 }
194 Some(value)
195}
196
197fn days_in_month(year: u16, month: u8) -> u8 {
198 match month {
199 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
200 4 | 6 | 9 | 11 => 30,
201 2 if is_leap_year(year) => 29,
202 2 => 28,
203 _ => 0,
204 }
205}
206
207fn is_leap_year(year: u16) -> bool {
208 let year = u32::from(year);
209 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
210}