//! The `datetime` string format. //! //! atproto's datetime is a *subset* of both RFC 3339 and ISO 8601, and the //! interesting part is everything the subset excludes. Every field is //! zero-padded to a fixed width, the `T` separator and the `Z` designator are //! uppercase only, seconds are mandatory, a timezone is mandatory, and //! `-00:00` — RFC 3339's "offset unknown" — is refused because ISO 8601 does //! not have it. The result is a format where two timestamps naming the same //! instant are the same string far more often than RFC 3339 would give. //! //! Syntax is not the whole check. `1985-13-12T23:20:50.123Z` is well formed //! and names month thirteen, so the calendar is validated too: upstream keeps //! those cases in a separate `datetime_parse_invalid.txt` precisely because an //! implementation that only matches the shape looks green while accepting //! them. /// Why a string is not a valid atproto datetime. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum DatetimeError { /// The overall shape is wrong: a field of the wrong width, a separator /// that is not the one required, or a missing component. #[error("datetime is malformed: {0}")] Malformed(&'static str), /// The timezone designator is absent, which atproto requires. #[error("datetime has no timezone designator")] MissingTimezone, /// `-00:00`, which RFC 3339 defines as "offset unknown" and ISO 8601 does /// not permit at all. #[error("datetime uses `-00:00`, which is not a valid ISO 8601 offset")] NegativeZeroOffset, /// A field is syntactically fine and outside its range: month thirteen, /// hour twenty-five, day zero. #[error("datetime field {field} is out of range: {value}")] OutOfRange { /// Which component was wrong. field: &'static str, /// The value it held. value: u32, }, /// A day that does not exist in that month of that year. #[error("datetime names {year:04}-{month:02}-{day:02}, which is not a real date")] NoSuchDay { /// Year as written. year: u32, /// Month as written. month: u32, /// Day as written. day: u32, }, /// The offset moves the instant before `0000-01-01T00:00:00Z`. /// /// `0000-01-01T00:00:00+01:00` is well formed and normalizes to an hour /// before the earliest representable instant, so it names no time at all. #[error("datetime normalizes to before 0000-01-01T00:00:00Z")] BeforeEpoch, } /// Checks a string against the atproto datetime rules, syntax and calendar. /// /// ``` /// use didbot_schema::format::datetime; /// assert!(datetime::validate("1985-04-12T23:20:50.123Z").is_ok()); /// assert!(datetime::validate("1985-04-12T23:20:50.123-07:00").is_ok()); /// assert!(datetime::validate("1985-04-12t23:20:50.123Z").is_err()); // lowercase `t` /// assert!(datetime::validate("1985-04-12T23:20:50.123").is_err()); // no timezone /// assert!(datetime::validate("1985-13-12T23:20:50.123Z").is_err()); // month thirteen /// ``` pub fn validate(value: &str) -> Result<(), DatetimeError> { let bytes = value.as_bytes(); // Shortest legal form is `YYYY-MM-DDTHH:MM:SSZ`. if bytes.len() < 20 { return Err(DatetimeError::Malformed("too short to be a datetime")); } if !value.is_ascii() { // An en-dash reads as a hyphen and is not one; the vectors carry both. return Err(DatetimeError::Malformed("non-ascii character")); } let digits = |from: usize, len: usize| -> Option { let slice = bytes.get(from..from + len)?; if !slice.iter().all(u8::is_ascii_digit) { return None; } std::str::from_utf8(slice).ok()?.parse().ok() }; let literal = |at: usize, ch: u8| bytes.get(at) == Some(&ch); let year = digits(0, 4).ok_or(DatetimeError::Malformed("year is not four digits"))?; if !literal(4, b'-') { return Err(DatetimeError::Malformed("expected `-` after the year")); } let month = digits(5, 2).ok_or(DatetimeError::Malformed("month is not two digits"))?; if !literal(7, b'-') { return Err(DatetimeError::Malformed("expected `-` after the month")); } let day = digits(8, 2).ok_or(DatetimeError::Malformed("day is not two digits"))?; if !literal(10, b'T') { return Err(DatetimeError::Malformed( "expected an uppercase `T` separator", )); } let hour = digits(11, 2).ok_or(DatetimeError::Malformed("hour is not two digits"))?; if !literal(13, b':') { return Err(DatetimeError::Malformed("expected `:` after the hour")); } let minute = digits(14, 2).ok_or(DatetimeError::Malformed("minute is not two digits"))?; if !literal(16, b':') { return Err(DatetimeError::Malformed("expected `:` after the minute")); } let second = digits(17, 2).ok_or(DatetimeError::Malformed("second is not two digits"))?; let mut cursor = 19; if literal(cursor, b'.') { cursor += 1; let start = cursor; while bytes.get(cursor).is_some_and(u8::is_ascii_digit) { cursor += 1; } if cursor == start { // `1985-04-12T23:20:50.Z`: a decimal point with nothing after it. return Err(DatetimeError::Malformed("fractional seconds are empty")); } } let offset_minutes = match bytes.get(cursor) { None => return Err(DatetimeError::MissingTimezone), Some(b'Z') => { if cursor + 1 != bytes.len() { return Err(DatetimeError::Malformed("trailing characters after `Z`")); } 0i64 } Some(sign @ (b'+' | b'-')) => { let negative = *sign == b'-'; if cursor + 6 != bytes.len() { return Err(DatetimeError::Malformed("offset is not `±HH:MM`")); } let hours = digits(cursor + 1, 2) .ok_or(DatetimeError::Malformed("offset hour is not two digits"))?; if !literal(cursor + 3, b':') { return Err(DatetimeError::Malformed("expected `:` in the offset")); } let minutes = digits(cursor + 4, 2) .ok_or(DatetimeError::Malformed("offset minute is not two digits"))?; if hours > 23 { return Err(DatetimeError::OutOfRange { field: "offset hour", value: hours, }); } if minutes > 59 { return Err(DatetimeError::OutOfRange { field: "offset minute", value: minutes, }); } let total = i64::from(hours) * 60 + i64::from(minutes); if negative && total == 0 { return Err(DatetimeError::NegativeZeroOffset); } if negative { -total } else { total } } // A lowercase `z`, a space, anything else. Some(_) => return Err(DatetimeError::MissingTimezone), }; range("month", month, 1, 12)?; range("day", day, 1, 31)?; range("hour", hour, 0, 23)?; range("minute", minute, 0, 59)?; // Sixty is a leap second, which is a real value a clock can report. range("second", second, 0, 60)?; if day > days_in_month(year, month) { return Err(DatetimeError::NoSuchDay { year, month, day }); } // Only year zero can be pushed below the earliest representable instant, // so the general "minutes since the start of time" arithmetic is not // worth carrying: everything else is far away from the boundary. if year == 0 && offset_minutes > 0 { let into_year = i64::from(days_before_month(year, month) + day - 1) * 1440 + i64::from(hour) * 60 + i64::from(minute); if into_year - offset_minutes < 0 { return Err(DatetimeError::BeforeEpoch); } } Ok(()) } /// Bounds one component, naming it in the error. fn range(field: &'static str, value: u32, low: u32, high: u32) -> Result<(), DatetimeError> { if value < low || value > high { return Err(DatetimeError::OutOfRange { field, value }); } Ok(()) } /// Whether a year is a leap year in the proleptic Gregorian calendar. fn is_leap(year: u32) -> bool { (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) } /// How many days that month holds in that year. fn days_in_month(year: u32, month: u32) -> u32 { match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, 2 if is_leap(year) => 29, 2 => 28, // Unreachable: the month range is checked before this is called. _ => 0, } } /// Days elapsed in the year before the first of `month`. fn days_before_month(year: u32, month: u32) -> u32 { (1..month).map(|m| days_in_month(year, m)).sum() } #[cfg(test)] mod tests { use super::*; #[test] fn the_preferred_form_is_accepted() { assert!(validate("1985-04-12T23:20:50.123Z").is_ok()); } #[test] fn leap_days_follow_the_gregorian_rule() { assert!( validate("2000-02-29T00:00:00Z").is_ok(), "2000 is a leap year" ); assert!(validate("1900-02-29T00:00:00Z").is_err(), "1900 is not"); assert!(validate("2024-02-29T00:00:00Z").is_ok()); assert!(validate("2023-02-29T00:00:00Z").is_err()); } #[test] fn a_leap_second_is_a_real_value_and_sixty_one_is_not() { assert!(validate("2016-12-31T23:59:60Z").is_ok()); assert_eq!( validate("2016-12-31T23:59:61Z"), Err(DatetimeError::OutOfRange { field: "second", value: 61 }) ); } #[test] fn offset_unknown_is_refused_but_plus_zero_is_not() { assert_eq!( validate("1985-04-12T23:20:50.123-00:00"), Err(DatetimeError::NegativeZeroOffset) ); assert!(validate("1985-04-12T23:20:50.123+00:00").is_ok()); } #[test] fn year_zero_cannot_be_pushed_before_the_start_of_time() { assert_eq!( validate("0000-01-01T00:00:00+01:00"), Err(DatetimeError::BeforeEpoch) ); assert!(validate("0000-01-01T00:00:00-01:00").is_ok()); assert!(validate("0000-01-02T00:00:00+01:00").is_ok()); } }