Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277//! 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<u32> { 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()); }}