Skip to main content

obby_proto/
servertime.rs

1//! The `server-time` tag.
2//!
3//! The tag carries an ISO 8601 instant in UTC, such as `2026-09-06T10:00:00.000Z`. Turning it into
4//! milliseconds is the whole job, and a date library is far more than that costs: everything here
5//! has to build for wasm and for a target with no `std`, and a calendar crate is a large dependency
6//! to carry for one conversion.
7
8use alloc::string::String;
9use core::fmt::Write as _;
10
11/// Read a `server-time` value as milliseconds since the Unix epoch.
12///
13/// Returns `None` for anything that is not a well-formed UTC instant, and for any instant before
14/// 1970, which no `server-time` legitimately carries. The caller should then stamp the message with
15/// its own clock: a message with a nonsense timestamp sorts into the wrong place forever, which is
16/// worse than one stamped a few milliseconds late.
17pub fn parse(value: &str) -> Option<u64> {
18    let (date, rest) = value.split_once('T')?;
19    let time = rest.strip_suffix('Z')?;
20
21    let mut date_parts = date.split('-');
22    let year: i64 = date_parts.next()?.parse().ok()?;
23    let month: u32 = date_parts.next()?.parse().ok()?;
24    let day: u32 = date_parts.next()?.parse().ok()?;
25    // the calendar arithmetic below overflows on an absurd year, and ISO 8601 gives four digits
26    // anyway, so anything outside that is not a timestamp we should be doing sums on
27    if date_parts.next().is_some()
28        || !(1..=9999).contains(&year)
29        || !(1..=12).contains(&month)
30        || !(1..=31).contains(&day)
31    {
32        return None;
33    }
34
35    let (clock, fraction) = time.split_once('.').unwrap_or((time, "0"));
36    let mut clock_parts = clock.split(':');
37    let hour: u64 = clock_parts.next()?.parse().ok()?;
38    let minute: u64 = clock_parts.next()?.parse().ok()?;
39    let second: u64 = clock_parts.next()?.parse().ok()?;
40    if clock_parts.next().is_some() || hour > 23 || minute > 59 || second > 60 {
41        return None;
42    }
43
44    // a fraction is however many digits the server felt like sending, so scale it to milliseconds
45    // rather than assuming three
46    let millis = millis_from_fraction(fraction)?;
47
48    let days = days_from_civil(year, month, day);
49    let seconds = days
50        .checked_mul(86_400)?
51        .checked_add(i64::try_from(hour * 3600 + minute * 60 + second).ok()?)?;
52    u64::try_from(seconds.checked_mul(1000)?.checked_add(i64::from(millis))?).ok()
53}
54
55/// Write milliseconds since the Unix epoch as a `server-time` value.
56///
57/// Always three fractional digits and always UTC, which is the form the tag is defined in and the
58/// one [`parse`] reads back exactly.
59pub fn format(unix_ms: u64) -> String {
60    let (seconds, millis) = (unix_ms / 1000, unix_ms % 1000);
61    let (days, second_of_day) = (seconds / 86_400, seconds % 86_400);
62    let (year, month, day) = civil_from_days(days);
63    let mut out = String::new();
64    let _ = write!(
65        out,
66        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
67        second_of_day / 3600,
68        (second_of_day / 60) % 60,
69        second_of_day % 60,
70    );
71    out
72}
73
74fn millis_from_fraction(fraction: &str) -> Option<u16> {
75    if !fraction.bytes().all(|b| b.is_ascii_digit()) {
76        return None;
77    }
78    let digits = fraction.get(..3).unwrap_or(fraction);
79    let value: u16 = digits.parse().ok()?;
80    Some(match digits.len() {
81        0 => 0,
82        1 => value * 100,
83        2 => value * 10,
84        _ => value,
85    })
86}
87
88/// Days from the Unix epoch to a calendar date, by Howard Hinnant's `days_from_civil`.
89///
90/// It is correct for every proleptic Gregorian date rather than only for a window of years, which a
91/// simpler leap-year loop would not be.
92fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
93    let year = if month <= 2 { year - 1 } else { year };
94    let era = if year >= 0 { year } else { year - 399 } / 400;
95    let year_of_era = year - era * 400;
96    let month = i64::from(month);
97    let shifted_month = if month > 2 { month - 3 } else { month + 9 };
98    let day_of_year = (153 * shifted_month + 2) / 5 + i64::from(day) - 1;
99    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
100    era * 146_097 + day_of_era - 719_468
101}
102
103/// A calendar date from days since the Unix epoch, the inverse of [`days_from_civil`].
104///
105/// Howard Hinnant's `civil_from_days`, restricted to dates at or after the epoch, which is all a
106/// `u64` of milliseconds can express.
107fn civil_from_days(days: u64) -> (u64, u64, u64) {
108    let era_days = days + 719_468;
109    let era = era_days / 146_097;
110    let day_of_era = era_days % 146_097;
111    let year_of_era =
112        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
113    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
114    let shifted_month = (5 * day_of_year + 2) / 153;
115    let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
116    let month = if shifted_month < 10 {
117        shifted_month + 3
118    } else {
119        shifted_month - 9
120    };
121    let year = year_of_era + era * 400 + u64::from(month <= 2);
122    (year, month, day)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn reads_the_epoch() {
131        assert_eq!(parse("1970-01-01T00:00:00.000Z"), Some(0));
132    }
133
134    #[test]
135    fn reads_a_known_instant() {
136        assert_eq!(parse("2026-09-06T10:00:00.000Z"), Some(1_788_688_800_000));
137    }
138
139    #[test]
140    fn keeps_the_milliseconds() {
141        let base = parse("2026-09-06T10:00:00.000Z").expect("valid");
142        assert_eq!(parse("2026-09-06T10:00:00.123Z"), Some(base + 123));
143    }
144
145    #[test]
146    fn scales_a_fraction_that_is_not_three_digits() {
147        let base = parse("2026-09-06T10:00:00.000Z").expect("valid");
148        assert_eq!(parse("2026-09-06T10:00:00.5Z"), Some(base + 500));
149        assert_eq!(parse("2026-09-06T10:00:00.05Z"), Some(base + 50));
150        assert_eq!(parse("2026-09-06T10:00:00.123456Z"), Some(base + 123));
151    }
152
153    #[test]
154    fn a_missing_fraction_is_allowed() {
155        assert_eq!(
156            parse("2026-09-06T10:00:00Z"),
157            parse("2026-09-06T10:00:00.000Z")
158        );
159    }
160
161    #[test]
162    fn handles_a_leap_day() {
163        let feb = parse("2024-02-29T00:00:00.000Z").expect("2024 is a leap year");
164        let mar = parse("2024-03-01T00:00:00.000Z").expect("valid");
165        assert_eq!(mar - feb, 86_400_000);
166    }
167
168    #[test]
169    fn handles_the_four_hundred_year_rule() {
170        // 1900 was not a leap year and 2000 was, which a naive divisible-by-four test gets wrong.
171        // These run against the calendar directly because 1900 predates the epoch that `parse`
172        // returns from.
173        assert_eq!(
174            days_from_civil(1900, 3, 1) - days_from_civil(1900, 2, 28),
175            1,
176            "1900 is divisible by 100 and not by 400, so it has no 29th"
177        );
178        assert_eq!(
179            days_from_civil(2000, 3, 1) - days_from_civil(2000, 2, 28),
180            2
181        );
182    }
183
184    #[test]
185    fn refuses_a_year_the_calendar_arithmetic_cannot_hold() {
186        // a hostile server sending this must not take the process down
187        assert_eq!(parse("99999999999999999-01-01T00:00:00.000Z"), None);
188        assert_eq!(parse("-4713-01-01T00:00:00.000Z"), None);
189        assert_eq!(parse("10000-01-01T00:00:00.000Z"), None);
190    }
191
192    #[test]
193    fn refuses_an_instant_before_the_epoch() {
194        assert_eq!(parse("1969-12-31T23:59:59.000Z"), None);
195    }
196
197    #[test]
198    fn accepts_a_leap_second() {
199        assert!(parse("2016-12-31T23:59:60.000Z").is_some());
200    }
201
202    #[test]
203    fn days_advance_by_exactly_one_day() {
204        let a = parse("2026-09-06T00:00:00.000Z").expect("valid");
205        let b = parse("2026-09-07T00:00:00.000Z").expect("valid");
206        assert_eq!(b - a, 86_400_000);
207    }
208
209    #[test]
210    fn writes_the_epoch() {
211        assert_eq!(format(0), "1970-01-01T00:00:00.000Z");
212    }
213
214    #[test]
215    fn writes_a_known_instant() {
216        assert_eq!(format(1_788_688_800_123), "2026-09-06T10:00:00.123Z");
217    }
218
219    #[test]
220    fn pads_every_field() {
221        assert_eq!(format(1_041_816_065_007), "2003-01-06T01:21:05.007Z");
222    }
223
224    #[test]
225    fn writes_a_leap_day() {
226        assert_eq!(format(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
227    }
228
229    #[test]
230    fn round_trips_through_parse() {
231        for ms in [
232            0,
233            1,
234            999,
235            86_399_999,
236            86_400_000,
237            951_782_400_000,
238            1_788_688_800_123,
239            253_402_300_799_999,
240        ] {
241            let text = format(ms);
242            assert_eq!(parse(&text), Some(ms), "{text} should read back as {ms}");
243        }
244    }
245
246    #[test]
247    fn refuses_anything_malformed() {
248        assert_eq!(parse(""), None);
249        assert_eq!(parse("2026-09-06"), None, "a date with no time");
250        assert_eq!(parse("2026-09-06T10:00:00.000"), None, "no zone marker");
251        assert_eq!(
252            parse("2026-09-06T10:00:00.000+01:00"),
253            None,
254            "only UTC is defined"
255        );
256        assert_eq!(
257            parse("2026-13-06T10:00:00.000Z"),
258            None,
259            "month out of range"
260        );
261        assert_eq!(parse("2026-09-06T24:00:00.000Z"), None, "hour out of range");
262        assert_eq!(parse("2026-09-06T10:00.000Z"), None, "no seconds");
263        assert_eq!(parse("not-a-date"), None);
264        assert_eq!(
265            parse("2026-09-06T10:00:00.abcZ"),
266            None,
267            "a non-numeric fraction"
268        );
269    }
270}