Skip to main content

didbot_schema/format/
datetime.rs

1//! The `datetime` string format.
2//!
3//! atproto's datetime is a *subset* of both RFC 3339 and ISO 8601, and the
4//! interesting part is everything the subset excludes. Every field is
5//! zero-padded to a fixed width, the `T` separator and the `Z` designator are
6//! uppercase only, seconds are mandatory, a timezone is mandatory, and
7//! `-00:00` — RFC 3339's "offset unknown" — is refused because ISO 8601 does
8//! not have it. The result is a format where two timestamps naming the same
9//! instant are the same string far more often than RFC 3339 would give.
10//!
11//! Syntax is not the whole check. `1985-13-12T23:20:50.123Z` is well formed
12//! and names month thirteen, so the calendar is validated too: upstream keeps
13//! those cases in a separate `datetime_parse_invalid.txt` precisely because an
14//! implementation that only matches the shape looks green while accepting
15//! them.
16
17use time::format_description::well_known::Rfc3339;
18use time::{OffsetDateTime, UtcOffset};
19
20/// Writes `at` in this format: RFC 3339, in UTC, with the `Z` designator.
21///
22/// RFC 3339 has no form for a year outside `0000..=9999`, so an instant
23/// there is written as the epoch: a string that is not a datetime would
24/// fail every lexicon field it goes into.
25///
26/// ```
27/// use didbot_schema::format::datetime;
28/// let at = time::macros::datetime!(1985-04-12 16:20:50.123 -07:00);
29/// assert_eq!(datetime::render(at), "1985-04-12T23:20:50.123Z");
30/// ```
31pub fn render(at: OffsetDateTime) -> String {
32    at.to_offset(UtcOffset::UTC)
33        .format(&Rfc3339)
34        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
35}
36
37/// Why a string is not a valid atproto datetime.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum DatetimeError {
40    /// The overall shape is wrong: a field of the wrong width, a separator
41    /// that is not the one required, or a missing component.
42    #[error("datetime is malformed: {0}")]
43    Malformed(&'static str),
44    /// The timezone designator is absent, which atproto requires.
45    #[error("datetime has no timezone designator")]
46    MissingTimezone,
47    /// `-00:00`, which RFC 3339 defines as "offset unknown" and ISO 8601 does
48    /// not permit at all.
49    #[error("datetime uses `-00:00`, which is not a valid ISO 8601 offset")]
50    NegativeZeroOffset,
51    /// A field is syntactically fine and outside its range: month thirteen,
52    /// hour twenty-five, day zero.
53    #[error("datetime field {field} is out of range: {value}")]
54    OutOfRange {
55        /// Which component was wrong.
56        field: &'static str,
57        /// The value it held.
58        value: u32,
59    },
60    /// A day that does not exist in that month of that year.
61    #[error("datetime names {year:04}-{month:02}-{day:02}, which is not a real date")]
62    NoSuchDay {
63        /// Year as written.
64        year: u32,
65        /// Month as written.
66        month: u32,
67        /// Day as written.
68        day: u32,
69    },
70    /// The offset moves the instant before `0000-01-01T00:00:00Z`.
71    ///
72    /// `0000-01-01T00:00:00+01:00` is well formed and normalizes to an hour
73    /// before the earliest representable instant, so it names no time at all.
74    #[error("datetime normalizes to before 0000-01-01T00:00:00Z")]
75    BeforeEpoch,
76}
77
78/// Checks a string against the atproto datetime rules, syntax and calendar.
79///
80/// ```
81/// use didbot_schema::format::datetime;
82/// assert!(datetime::validate("1985-04-12T23:20:50.123Z").is_ok());
83/// assert!(datetime::validate("1985-04-12T23:20:50.123-07:00").is_ok());
84/// assert!(datetime::validate("1985-04-12t23:20:50.123Z").is_err()); // lowercase `t`
85/// assert!(datetime::validate("1985-04-12T23:20:50.123").is_err()); // no timezone
86/// assert!(datetime::validate("1985-13-12T23:20:50.123Z").is_err()); // month thirteen
87/// ```
88pub fn validate(value: &str) -> Result<(), DatetimeError> {
89    let bytes = value.as_bytes();
90    // Shortest legal form is `YYYY-MM-DDTHH:MM:SSZ`.
91    if bytes.len() < 20 {
92        return Err(DatetimeError::Malformed("too short to be a datetime"));
93    }
94    if !value.is_ascii() {
95        // An en-dash reads as a hyphen and is not one; the vectors carry both.
96        return Err(DatetimeError::Malformed("non-ascii character"));
97    }
98
99    let digits = |from: usize, len: usize| -> Option<u32> {
100        let slice = bytes.get(from..from + len)?;
101        if !slice.iter().all(u8::is_ascii_digit) {
102            return None;
103        }
104        std::str::from_utf8(slice).ok()?.parse().ok()
105    };
106    let literal = |at: usize, ch: u8| bytes.get(at) == Some(&ch);
107
108    let year = digits(0, 4).ok_or(DatetimeError::Malformed("year is not four digits"))?;
109    if !literal(4, b'-') {
110        return Err(DatetimeError::Malformed("expected `-` after the year"));
111    }
112    let month = digits(5, 2).ok_or(DatetimeError::Malformed("month is not two digits"))?;
113    if !literal(7, b'-') {
114        return Err(DatetimeError::Malformed("expected `-` after the month"));
115    }
116    let day = digits(8, 2).ok_or(DatetimeError::Malformed("day is not two digits"))?;
117    if !literal(10, b'T') {
118        return Err(DatetimeError::Malformed(
119            "expected an uppercase `T` separator",
120        ));
121    }
122    let hour = digits(11, 2).ok_or(DatetimeError::Malformed("hour is not two digits"))?;
123    if !literal(13, b':') {
124        return Err(DatetimeError::Malformed("expected `:` after the hour"));
125    }
126    let minute = digits(14, 2).ok_or(DatetimeError::Malformed("minute is not two digits"))?;
127    if !literal(16, b':') {
128        return Err(DatetimeError::Malformed("expected `:` after the minute"));
129    }
130    let second = digits(17, 2).ok_or(DatetimeError::Malformed("second is not two digits"))?;
131
132    let mut cursor = 19;
133    if literal(cursor, b'.') {
134        cursor += 1;
135        let start = cursor;
136        while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
137            cursor += 1;
138        }
139        if cursor == start {
140            // `1985-04-12T23:20:50.Z`: a decimal point with nothing after it.
141            return Err(DatetimeError::Malformed("fractional seconds are empty"));
142        }
143    }
144
145    let offset_minutes = match bytes.get(cursor) {
146        None => return Err(DatetimeError::MissingTimezone),
147        Some(b'Z') => {
148            if cursor + 1 != bytes.len() {
149                return Err(DatetimeError::Malformed("trailing characters after `Z`"));
150            }
151            0i64
152        }
153        Some(sign @ (b'+' | b'-')) => {
154            let negative = *sign == b'-';
155            if cursor + 6 != bytes.len() {
156                return Err(DatetimeError::Malformed("offset is not `±HH:MM`"));
157            }
158            let hours = digits(cursor + 1, 2)
159                .ok_or(DatetimeError::Malformed("offset hour is not two digits"))?;
160            if !literal(cursor + 3, b':') {
161                return Err(DatetimeError::Malformed("expected `:` in the offset"));
162            }
163            let minutes = digits(cursor + 4, 2)
164                .ok_or(DatetimeError::Malformed("offset minute is not two digits"))?;
165            if hours > 23 {
166                return Err(DatetimeError::OutOfRange {
167                    field: "offset hour",
168                    value: hours,
169                });
170            }
171            if minutes > 59 {
172                return Err(DatetimeError::OutOfRange {
173                    field: "offset minute",
174                    value: minutes,
175                });
176            }
177            let total = i64::from(hours) * 60 + i64::from(minutes);
178            if negative && total == 0 {
179                return Err(DatetimeError::NegativeZeroOffset);
180            }
181            if negative {
182                -total
183            } else {
184                total
185            }
186        }
187        // A lowercase `z`, a space, anything else.
188        Some(_) => return Err(DatetimeError::MissingTimezone),
189    };
190
191    range("month", month, 1, 12)?;
192    range("day", day, 1, 31)?;
193    range("hour", hour, 0, 23)?;
194    range("minute", minute, 0, 59)?;
195    // Sixty is a leap second, which is a real value a clock can report.
196    range("second", second, 0, 60)?;
197    if day > days_in_month(year, month) {
198        return Err(DatetimeError::NoSuchDay { year, month, day });
199    }
200
201    // Only year zero can be pushed below the earliest representable instant,
202    // so the general "minutes since the start of time" arithmetic is not
203    // worth carrying: everything else is far away from the boundary.
204    if year == 0 && offset_minutes > 0 {
205        let into_year = i64::from(days_before_month(year, month) + day - 1) * 1440
206            + i64::from(hour) * 60
207            + i64::from(minute);
208        if into_year - offset_minutes < 0 {
209            return Err(DatetimeError::BeforeEpoch);
210        }
211    }
212
213    Ok(())
214}
215
216/// Bounds one component, naming it in the error.
217fn range(field: &'static str, value: u32, low: u32, high: u32) -> Result<(), DatetimeError> {
218    if value < low || value > high {
219        return Err(DatetimeError::OutOfRange { field, value });
220    }
221    Ok(())
222}
223
224/// Whether a year is a leap year in the proleptic Gregorian calendar.
225fn is_leap(year: u32) -> bool {
226    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
227}
228
229/// How many days that month holds in that year.
230fn days_in_month(year: u32, month: u32) -> u32 {
231    match month {
232        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
233        4 | 6 | 9 | 11 => 30,
234        2 if is_leap(year) => 29,
235        2 => 28,
236        // Unreachable: the month range is checked before this is called.
237        _ => 0,
238    }
239}
240
241/// Days elapsed in the year before the first of `month`.
242fn days_before_month(year: u32, month: u32) -> u32 {
243    (1..month).map(|m| days_in_month(year, m)).sum()
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn the_preferred_form_is_accepted() {
252        assert!(validate("1985-04-12T23:20:50.123Z").is_ok());
253    }
254
255    #[test]
256    fn leap_days_follow_the_gregorian_rule() {
257        assert!(
258            validate("2000-02-29T00:00:00Z").is_ok(),
259            "2000 is a leap year"
260        );
261        assert!(validate("1900-02-29T00:00:00Z").is_err(), "1900 is not");
262        assert!(validate("2024-02-29T00:00:00Z").is_ok());
263        assert!(validate("2023-02-29T00:00:00Z").is_err());
264    }
265
266    #[test]
267    fn a_leap_second_is_a_real_value_and_sixty_one_is_not() {
268        assert!(validate("2016-12-31T23:59:60Z").is_ok());
269        assert_eq!(
270            validate("2016-12-31T23:59:61Z"),
271            Err(DatetimeError::OutOfRange {
272                field: "second",
273                value: 61
274            })
275        );
276    }
277
278    #[test]
279    fn offset_unknown_is_refused_but_plus_zero_is_not() {
280        assert_eq!(
281            validate("1985-04-12T23:20:50.123-00:00"),
282            Err(DatetimeError::NegativeZeroOffset)
283        );
284        assert!(validate("1985-04-12T23:20:50.123+00:00").is_ok());
285    }
286
287    /// What [`render`] writes is what [`validate`] accepts, including for
288    /// an instant written with an offset.
289    #[test]
290    fn a_rendered_instant_is_a_valid_datetime() {
291        for at in [
292            time::macros::datetime!(2026-09-22 21:00:00 +02:00),
293            time::macros::datetime!(2026-09-22 19:00:00.5 UTC),
294            time::macros::datetime!(0000-01-01 00:00:00 -01:00),
295        ] {
296            let text = render(at);
297            assert!(text.ends_with('Z'), "{text} is not in UTC");
298            assert_eq!(validate(&text), Ok(()), "{text}");
299        }
300        assert_eq!(
301            render(time::macros::datetime!(0000-01-01 00:00:00 +01:00)),
302            "1970-01-01T00:00:00Z",
303            "an instant before year zero has no RFC 3339 form"
304        );
305    }
306
307    #[test]
308    fn year_zero_cannot_be_pushed_before_the_start_of_time() {
309        assert_eq!(
310            validate("0000-01-01T00:00:00+01:00"),
311            Err(DatetimeError::BeforeEpoch)
312        );
313        assert!(validate("0000-01-01T00:00:00-01:00").is_ok());
314        assert!(validate("0000-01-02T00:00:00+01:00").is_ok());
315    }
316}