Skip to main content

dash_runtime/timing/
datetime.rs

1//! `xs:dateTime` (accepted subset), HTTP `Date` and NTP timestamp readers.
2//!
3//! The accepted `xs:dateTime` subset is project policy, recorded with its
4//! rationale and the full accept/reject table in
5//! `private/specs-verified/dash/docs-specs/xs-datetime-subset.md` (W3C XML Schema Part 2, 3.2.7;
6//! ISO/IEC 23009-1 5.8.5.7 `DIRECT-VALUE`). Calendar arithmetic is jiff's;
7//! nothing here reads a clock.
8
9use jiff::Span;
10use jiff::civil::{Date, DateTime};
11use jiff::tz::{Offset, TimeZone};
12use thiserror::Error;
13
14const MICROS_PER_SECOND: i64 = 1_000_000;
15const NANOS_DIGITS: usize = 9;
16const MICROS_DIGITS: usize = 6;
17const SECONDS_PER_HOUR: i32 = 3_600;
18const SECONDS_PER_MINUTE: i32 = 60;
19const MAX_ZONE_HOUR: u8 = 14;
20const MAX_MINUTE: u8 = 59;
21const MAX_SECOND: u8 = 59;
22const END_OF_DAY_HOUR: u8 = 24;
23const FIRST_YEAR: i16 = 1;
24const YEAR_DIGITS: usize = 4;
25const NTP_FRACTION_BITS: u32 = 32;
26const NTP_TIMESTAMP_LEN: usize = 8;
27const HTTP_DATE_RFC850: &str = "%A, %d-%b-%y %H:%M:%S GMT";
28const HTTP_DATE_ASCTIME: &str = "%a %b %e %H:%M:%S %Y";
29
30/// Why a time string or timestamp was rejected.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
32#[non_exhaustive]
33pub enum DateTimeError {
34    /// Not in the accepted lexical form.
35    #[error("not an accepted xs:dateTime: {0}")]
36    Syntax(&'static str),
37    /// A field is outside its range (month 13, 29 February in a common year,
38    /// hour 25, zone beyond +14:00 ...).
39    #[error("xs:dateTime field out of range: {0}")]
40    Field(&'static str),
41    /// The instant cannot be represented.
42    #[error("instant outside the representable range")]
43    OutOfRange,
44    /// Not an HTTP `Date` value.
45    #[error("not an HTTP Date value")]
46    HttpDate,
47    /// Not an 8-byte NTP timestamp.
48    #[error("not an 8-byte NTP timestamp")]
49    Ntp,
50}
51
52/// A parsed instant plus how finely the source stated it.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct XsDateTime {
56    /// Microseconds since 1970-01-01T00:00:00Z; sub-microsecond digits are
57    /// truncated.
58    pub unix_us: i64,
59    /// Number of fraction digits the text carried (0 when none, capped at 9).
60    pub fraction_digits: u8,
61}
62
63impl XsDateTime {
64    /// The resolution the text implies, in microseconds: one second without a
65    /// fraction, 10^(6 - digits) microseconds with one (1 for 6 or more digits).
66    #[must_use]
67    pub fn resolution_us(&self) -> u64 {
68        let digits = usize::from(self.fraction_digits).min(MICROS_DIGITS);
69        let mut res = MICROS_PER_SECOND.unsigned_abs();
70        if self.fraction_digits > 0 {
71            res = 10u64.pow(u32::try_from(MICROS_DIGITS - digits).unwrap_or(0));
72        }
73        res
74    }
75}
76
77struct Cursor<'a> {
78    b: &'a [u8],
79    i: usize,
80}
81
82impl Cursor<'_> {
83    fn eat(&mut self, c: u8) -> bool {
84        if self.b.get(self.i) == Some(&c) {
85            self.i += 1;
86            true
87        } else {
88            false
89        }
90    }
91
92    fn digits(&mut self, n: usize) -> Result<u32, DateTimeError> {
93        let end = self.i + n;
94        let slice = self
95            .b
96            .get(self.i..end)
97            .ok_or(DateTimeError::Syntax("truncated"))?;
98        if !slice.iter().all(u8::is_ascii_digit) {
99            return Err(DateTimeError::Syntax("expected digits"));
100        }
101        self.i = end;
102        Ok(slice
103            .iter()
104            .fold(0u32, |acc, d| acc * 10 + u32::from(d - b'0')))
105    }
106
107    fn expect(&mut self, c: u8, what: &'static str) -> Result<(), DateTimeError> {
108        if self.eat(c) {
109            Ok(())
110        } else {
111            Err(DateTimeError::Syntax(what))
112        }
113    }
114}
115
116fn narrow<T: TryFrom<u32>>(v: u32) -> Result<T, DateTimeError> {
117    T::try_from(v).map_err(|_| DateTimeError::Field("value"))
118}
119
120/// Parse an `xs:dateTime` under the accepted-subset policy.
121///
122/// # Errors
123/// [`DateTimeError`] for every form outside the subset; never a wrap.
124pub fn parse_xs_datetime(text: &str) -> Result<XsDateTime, DateTimeError> {
125    let trimmed = text.trim_matches([' ', '\t', '\n', '\r']);
126    let mut c = Cursor {
127        b: trimmed.as_bytes(),
128        i: 0,
129    };
130    let year: i16 = narrow(c.digits(YEAR_DIGITS)?)?;
131    if year < FIRST_YEAR {
132        return Err(DateTimeError::Field("year"));
133    }
134    c.expect(b'-', "expected '-' after the year")?;
135    let month: u8 = narrow(c.digits(2)?)?;
136    c.expect(b'-', "expected '-' after the month")?;
137    let day: u8 = narrow(c.digits(2)?)?;
138    c.expect(b'T', "expected upper-case 'T'")?;
139    let hour: u8 = narrow(c.digits(2)?)?;
140    c.expect(b':', "expected ':' after the hour")?;
141    let minute: u8 = narrow(c.digits(2)?)?;
142    c.expect(b':', "expected ':' after the minute")?;
143    let second: u8 = narrow(c.digits(2)?)?;
144
145    let mut fraction_digits = 0usize;
146    let mut nanos: i32 = 0;
147    let mut fraction_zero = true;
148    if c.eat(b'.') {
149        let start = c.i;
150        while c.b.get(c.i).is_some_and(u8::is_ascii_digit) {
151            c.i += 1;
152        }
153        let frac = &c.b[start..c.i];
154        if frac.is_empty() {
155            return Err(DateTimeError::Syntax("empty fraction"));
156        }
157        fraction_digits = frac.len();
158        fraction_zero = frac.iter().all(|d| *d == b'0');
159        for d in frac.iter().take(NANOS_DIGITS) {
160            nanos = nanos * 10 + i32::from(d - b'0');
161        }
162        for _ in frac.len().min(NANOS_DIGITS)..NANOS_DIGITS {
163            nanos *= 10;
164        }
165    }
166
167    let offset = parse_zone(&mut c)?;
168    if c.i != c.b.len() {
169        return Err(DateTimeError::Syntax("trailing characters"));
170    }
171
172    if month == 0 || month > 12 {
173        return Err(DateTimeError::Field("month"));
174    }
175    let date = Date::new(
176        year,
177        i8::try_from(month).map_err(|_| DateTimeError::Field("month"))?,
178        i8::try_from(day).map_err(|_| DateTimeError::Field("day"))?,
179    )
180    .map_err(|_| DateTimeError::Field("day"))?;
181    if minute > MAX_MINUTE || second > MAX_SECOND {
182        return Err(DateTimeError::Field("minute or second"));
183    }
184    let (date, hour) = if hour == END_OF_DAY_HOUR {
185        if minute != 0 || second != 0 || !fraction_zero {
186            return Err(DateTimeError::Field("hour 24 with a non-zero remainder"));
187        }
188        let next = date
189            .checked_add(Span::new().days(1))
190            .map_err(|_| DateTimeError::OutOfRange)?;
191        (next, 0)
192    } else if hour > END_OF_DAY_HOUR {
193        return Err(DateTimeError::Field("hour"));
194    } else {
195        (date, hour)
196    };
197    let civil: DateTime = date.at(
198        i8::try_from(hour).map_err(|_| DateTimeError::Field("hour"))?,
199        i8::try_from(minute).map_err(|_| DateTimeError::Field("minute"))?,
200        i8::try_from(second).map_err(|_| DateTimeError::Field("second"))?,
201        nanos,
202    );
203    let ts = offset
204        .to_timestamp(civil)
205        .map_err(|_| DateTimeError::OutOfRange)?;
206    Ok(XsDateTime {
207        unix_us: ts.as_microsecond(),
208        fraction_digits: u8::try_from(fraction_digits.min(NANOS_DIGITS)).unwrap_or(0),
209    })
210}
211
212fn parse_zone(c: &mut Cursor<'_>) -> Result<Offset, DateTimeError> {
213    if c.i == c.b.len() {
214        return Ok(Offset::UTC);
215    }
216    if c.eat(b'Z') {
217        return Ok(Offset::UTC);
218    }
219    let sign: i32 = if c.eat(b'+') {
220        1
221    } else if c.eat(b'-') {
222        -1
223    } else {
224        return Err(DateTimeError::Syntax("unexpected character after the time"));
225    };
226    let h: u8 = narrow(c.digits(2)?)?;
227    c.expect(b':', "zone offset needs 'hh:mm'")?;
228    let m: u8 = narrow(c.digits(2)?)?;
229    if h > MAX_ZONE_HOUR || m > MAX_MINUTE || (h == MAX_ZONE_HOUR && m != 0) {
230        return Err(DateTimeError::Field("zone offset"));
231    }
232    let seconds = sign * (i32::from(h) * SECONDS_PER_HOUR + i32::from(m) * SECONDS_PER_MINUTE);
233    Offset::from_seconds(seconds).map_err(|_| DateTimeError::Field("zone offset"))
234}
235
236/// Parse an HTTP-date (RFC 9110 5.6.7) to Unix microseconds: the preferred
237/// IMF-fixdate and the two obsolete forms a recipient must still accept, the
238/// RFC 850 form (two-digit years follow jiff's POSIX window: 69-99 are 19xx,
239/// 00-68 are 20xx, so no clock is needed) and the asctime form (always UTC).
240/// Resolution is whole seconds. Used for the `Date` and `Retry-After` headers.
241///
242/// # Errors
243/// [`DateTimeError::HttpDate`] when `text` is not a date.
244pub fn parse_http_date(text: &str) -> Result<i64, DateTimeError> {
245    let text = text.trim();
246    if let Ok(t) = jiff::fmt::rfc2822::DateTimeParser::new().parse_timestamp(text) {
247        return Ok(t.as_microsecond());
248    }
249    [HTTP_DATE_RFC850, HTTP_DATE_ASCTIME]
250        .iter()
251        .find_map(|format| {
252            jiff::fmt::strtime::parse(format, text)
253                .and_then(|broken| broken.to_datetime())
254                .and_then(|datetime| datetime.to_zoned(jiff::tz::TimeZone::UTC))
255                .ok()
256        })
257        .map(|zoned| zoned.timestamp().as_microsecond())
258        .ok_or(DateTimeError::HttpDate)
259}
260
261/// Read a 64-bit NTP timestamp (RFC 5905 Figure 3: 32-bit seconds since
262/// 1900-01-01T00:00:00Z, 32-bit binary fraction; era 0) as Unix microseconds.
263///
264/// # Errors
265/// [`DateTimeError::Ntp`] unless `bytes` is exactly eight bytes.
266pub fn parse_ntp_timestamp(bytes: &[u8]) -> Result<i64, DateTimeError> {
267    let arr: [u8; NTP_TIMESTAMP_LEN] = bytes.try_into().map_err(|_| DateTimeError::Ntp)?;
268    let seconds = u32::from_be_bytes([arr[0], arr[1], arr[2], arr[3]]);
269    let fraction = u32::from_be_bytes([arr[4], arr[5], arr[6], arr[7]]);
270    let micros =
271        (u128::from(fraction) * u128::from(MICROS_PER_SECOND.unsigned_abs())) >> NTP_FRACTION_BITS;
272    let micros = i64::try_from(micros).map_err(|_| DateTimeError::Ntp)?;
273    let prime_epoch = Date::new(1900, 1, 1)
274        .map_err(|_| DateTimeError::Ntp)?
275        .at(0, 0, 0, 0)
276        .to_zoned(TimeZone::UTC)
277        .map_err(|_| DateTimeError::Ntp)?
278        .timestamp()
279        .as_second();
280    let unix_seconds = i64::from(seconds) + prime_epoch;
281    unix_seconds
282        .checked_mul(MICROS_PER_SECOND)
283        .and_then(|us| us.checked_add(micros))
284        .ok_or(DateTimeError::Ntp)
285}