Skip to main content

agent_first_data/
validation.rs

1/// Normalize a fixed UTC offset string to AFDATA canonical form.
2///
3/// Returns `"UTC"` for zero offset. Non-zero offsets return `+HH:MM` or
4/// `-HH:MM`. This helper handles fixed offsets only; IANA timezone names and
5/// DST rules are intentionally out of scope.
6pub 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
27/// Return true when `s` is an RFC 3339 `full-date` (`YYYY-MM-DD`).
28pub 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
45/// Return true when `s` is an RFC 3339 `partial-time` (`HH:MM:SS[.fraction]`).
46///
47/// AFDATA intentionally rejects `Z`/offset suffixes here: time-only fields are
48/// not instants and cannot be resolved through timezone rules without a date.
49pub 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
72/// Return true when `s` is a complete RFC 3339 `date-time`, such as
73/// `2026-02-14T10:30:00Z` or `2026-02-14T10:30:00.5+08:00`.
74///
75/// Composed from [`is_valid_rfc3339_date`] and [`is_valid_rfc3339_time`]: a
76/// `full-date`, a `T`/`t` separator, a `partial-time` (with optional fractional
77/// seconds), and a **mandatory** `time-offset` — either `Z`/`z` or `±HH:MM` with
78/// `HH` in `00..23` and `MM` in `00..59`. The offset is required, so a bare
79/// `2026-02-14T10:30:00` is rejected; a space separator is rejected (only `T`/`t`
80/// is accepted); and a leap second (`:60`) is rejected, matching
81/// [`is_valid_rfc3339_time`]. Non-ASCII input is rejected.
82pub 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
109/// Return true when `o` is an RFC 3339 `time-numoffset` (`±HH:MM`).
110fn 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
124/// Return true when `s` is a structurally well-formed BCP 47 (RFC 5646) language tag.
125///
126/// This is a grammar-level check, not a registry lookup. It accepts hyphen-separated
127/// ASCII-alphanumeric subtags (each 1-8 characters) whose primary subtag is a 2-3
128/// letter language code, or the `x`/`i` privateuse/grandfathered lead. It rejects the
129/// common mistakes: the POSIX-locale underscore form (`zh_CN`), empty or misplaced
130/// hyphens, non-ASCII, and out-of-range primaries such as `chinese`. It does not check
131/// that subtags are registered with IANA; a tool needing that guarantee validates further.
132pub 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}