Skip to main content

broadcast_dash/
xs_datetime.rs

1//! `xs:dateTime` (accepted subset) 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`, and the `http-ntp`/`direct` body).
7//! Calendar arithmetic is jiff's; nothing here reads a clock.
8
9use broadcast_common::units::MICROS_PER_SEC;
10use jiff::Span;
11use jiff::civil::{Date, DateTime};
12use jiff::tz::{Offset, TimeZone};
13use thiserror::Error;
14
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;
25/// Resolution of one NTP timestamp, in microseconds.
26pub const NTP_RESOLUTION_US: u64 = 1_000;
27const NTP_FRACTION_BITS: u32 = 32;
28const NTP_TIMESTAMP_LEN: usize = 8;
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 8-byte NTP timestamp.
45    #[error("not an 8-byte NTP timestamp")]
46    Ntp,
47}
48
49/// A parsed instant plus how finely the source stated it.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct XsDateTime {
53    /// Microseconds since 1970-01-01T00:00:00Z; sub-microsecond digits are
54    /// truncated.
55    pub unix_us: i64,
56    /// Number of fraction digits the text carried (0 when none, capped at 9).
57    pub fraction_digits: u8,
58}
59
60impl XsDateTime {
61    /// The resolution the text implies, in microseconds: one second without a
62    /// fraction, 10^(6 - digits) microseconds with one (1 for 6 or more digits).
63    #[must_use]
64    pub fn resolution_us(&self) -> u64 {
65        let digits = usize::from(self.fraction_digits).min(MICROS_DIGITS);
66        let mut res = (MICROS_PER_SEC as i64).unsigned_abs();
67        if self.fraction_digits > 0 {
68            res = 10u64.pow(u32::try_from(MICROS_DIGITS - digits).unwrap_or(0));
69        }
70        res
71    }
72}
73
74struct Cursor<'a> {
75    b: &'a [u8],
76    i: usize,
77}
78
79impl Cursor<'_> {
80    fn eat(&mut self, c: u8) -> bool {
81        if self.b.get(self.i) == Some(&c) {
82            self.i += 1;
83            true
84        } else {
85            false
86        }
87    }
88
89    fn digits(&mut self, n: usize) -> Result<u32, DateTimeError> {
90        let end = self.i + n;
91        let slice = self
92            .b
93            .get(self.i..end)
94            .ok_or(DateTimeError::Syntax("truncated"))?;
95        if !slice.iter().all(u8::is_ascii_digit) {
96            return Err(DateTimeError::Syntax("expected digits"));
97        }
98        self.i = end;
99        Ok(slice
100            .iter()
101            .fold(0u32, |acc, d| acc * 10 + u32::from(d - b'0')))
102    }
103
104    fn expect(&mut self, c: u8, what: &'static str) -> Result<(), DateTimeError> {
105        if self.eat(c) {
106            Ok(())
107        } else {
108            Err(DateTimeError::Syntax(what))
109        }
110    }
111}
112
113fn narrow<T: TryFrom<u32>>(v: u32) -> Result<T, DateTimeError> {
114    T::try_from(v).map_err(|_| DateTimeError::Field("value"))
115}
116
117/// Parse an `xs:dateTime` under the accepted-subset policy.
118///
119/// # Errors
120/// [`DateTimeError`] for every form outside the subset; never a wrap.
121pub fn parse_xs_datetime(text: &str) -> Result<XsDateTime, DateTimeError> {
122    let trimmed = text.trim_matches([' ', '\t', '\n', '\r']);
123    let mut c = Cursor {
124        b: trimmed.as_bytes(),
125        i: 0,
126    };
127    let year: i16 = narrow(c.digits(YEAR_DIGITS)?)?;
128    if year < FIRST_YEAR {
129        return Err(DateTimeError::Field("year"));
130    }
131    c.expect(b'-', "expected '-' after the year")?;
132    let month: u8 = narrow(c.digits(2)?)?;
133    c.expect(b'-', "expected '-' after the month")?;
134    let day: u8 = narrow(c.digits(2)?)?;
135    c.expect(b'T', "expected upper-case 'T'")?;
136    let hour: u8 = narrow(c.digits(2)?)?;
137    c.expect(b':', "expected ':' after the hour")?;
138    let minute: u8 = narrow(c.digits(2)?)?;
139    c.expect(b':', "expected ':' after the minute")?;
140    let second: u8 = narrow(c.digits(2)?)?;
141
142    let mut fraction_digits = 0usize;
143    let mut nanos: i32 = 0;
144    let mut fraction_zero = true;
145    if c.eat(b'.') {
146        let start = c.i;
147        while c.b.get(c.i).is_some_and(u8::is_ascii_digit) {
148            c.i += 1;
149        }
150        let frac = &c.b[start..c.i];
151        if frac.is_empty() {
152            return Err(DateTimeError::Syntax("empty fraction"));
153        }
154        fraction_digits = frac.len();
155        fraction_zero = frac.iter().all(|d| *d == b'0');
156        for d in frac.iter().take(NANOS_DIGITS) {
157            nanos = nanos * 10 + i32::from(d - b'0');
158        }
159        for _ in frac.len().min(NANOS_DIGITS)..NANOS_DIGITS {
160            nanos *= 10;
161        }
162    }
163
164    let offset = parse_zone(&mut c)?;
165    if c.i != c.b.len() {
166        return Err(DateTimeError::Syntax("trailing characters"));
167    }
168
169    if month == 0 || month > 12 {
170        return Err(DateTimeError::Field("month"));
171    }
172    let date = Date::new(
173        year,
174        i8::try_from(month).map_err(|_| DateTimeError::Field("month"))?,
175        i8::try_from(day).map_err(|_| DateTimeError::Field("day"))?,
176    )
177    .map_err(|_| DateTimeError::Field("day"))?;
178    if minute > MAX_MINUTE || second > MAX_SECOND {
179        return Err(DateTimeError::Field("minute or second"));
180    }
181    let (date, hour) = if hour == END_OF_DAY_HOUR {
182        if minute != 0 || second != 0 || !fraction_zero {
183            return Err(DateTimeError::Field("hour 24 with a non-zero remainder"));
184        }
185        let next = date
186            .checked_add(Span::new().days(1))
187            .map_err(|_| DateTimeError::OutOfRange)?;
188        (next, 0)
189    } else if hour > END_OF_DAY_HOUR {
190        return Err(DateTimeError::Field("hour"));
191    } else {
192        (date, hour)
193    };
194    let civil: DateTime = date.at(
195        i8::try_from(hour).map_err(|_| DateTimeError::Field("hour"))?,
196        i8::try_from(minute).map_err(|_| DateTimeError::Field("minute"))?,
197        i8::try_from(second).map_err(|_| DateTimeError::Field("second"))?,
198        nanos,
199    );
200    let ts = offset
201        .to_timestamp(civil)
202        .map_err(|_| DateTimeError::OutOfRange)?;
203    Ok(XsDateTime {
204        unix_us: ts.as_microsecond(),
205        fraction_digits: u8::try_from(fraction_digits.min(NANOS_DIGITS)).unwrap_or(0),
206    })
207}
208
209fn parse_zone(c: &mut Cursor<'_>) -> Result<Offset, DateTimeError> {
210    if c.i == c.b.len() {
211        return Ok(Offset::UTC);
212    }
213    if c.eat(b'Z') {
214        return Ok(Offset::UTC);
215    }
216    let sign: i32 = if c.eat(b'+') {
217        1
218    } else if c.eat(b'-') {
219        -1
220    } else {
221        return Err(DateTimeError::Syntax("unexpected character after the time"));
222    };
223    let h: u8 = narrow(c.digits(2)?)?;
224    c.expect(b':', "zone offset needs 'hh:mm'")?;
225    let m: u8 = narrow(c.digits(2)?)?;
226    if h > MAX_ZONE_HOUR || m > MAX_MINUTE || (h == MAX_ZONE_HOUR && m != 0) {
227        return Err(DateTimeError::Field("zone offset"));
228    }
229    let seconds = sign * (i32::from(h) * SECONDS_PER_HOUR + i32::from(m) * SECONDS_PER_MINUTE);
230    Offset::from_seconds(seconds).map_err(|_| DateTimeError::Field("zone offset"))
231}
232
233/// Read a 64-bit NTP timestamp (RFC 5905 Figure 3: 32-bit seconds since
234/// 1900-01-01T00:00:00Z, 32-bit binary fraction; era 0) as Unix microseconds.
235///
236/// # Errors
237/// [`DateTimeError::Ntp`] unless `bytes` is exactly eight bytes.
238pub fn parse_ntp_timestamp(bytes: &[u8]) -> Result<i64, DateTimeError> {
239    let arr: [u8; NTP_TIMESTAMP_LEN] = bytes.try_into().map_err(|_| DateTimeError::Ntp)?;
240    let seconds = u32::from_be_bytes([arr[0], arr[1], arr[2], arr[3]]);
241    let fraction = u32::from_be_bytes([arr[4], arr[5], arr[6], arr[7]]);
242    let micros = (u128::from(fraction) * u128::from((MICROS_PER_SEC as i64).unsigned_abs()))
243        >> NTP_FRACTION_BITS;
244    let micros = i64::try_from(micros).map_err(|_| DateTimeError::Ntp)?;
245    let prime_epoch = Date::new(1900, 1, 1)
246        .map_err(|_| DateTimeError::Ntp)?
247        .at(0, 0, 0, 0)
248        .to_zoned(TimeZone::UTC)
249        .map_err(|_| DateTimeError::Ntp)?
250        .timestamp()
251        .as_second();
252    let unix_seconds = i64::from(seconds) + prime_epoch;
253    unix_seconds
254        .checked_mul(MICROS_PER_SEC as i64)
255        .and_then(|us| us.checked_add(micros))
256        .ok_or(DateTimeError::Ntp)
257}
258
259/// Format Unix microseconds as an accepted `xs:dateTime` string (UTC, `Z`).
260///
261/// The inverse of [`parse_xs_datetime`] for the model's `*_us` fields: the
262/// parser recovers the same instant from the emitted text.
263pub fn format_xs_datetime(unix_us: i64) -> Result<String, DateTimeError> {
264    let ts = jiff::Timestamp::from_microsecond(unix_us).map_err(|_| DateTimeError::OutOfRange)?;
265    let s = ts.strftime("%Y-%m-%dT%H:%M:%S%.6fZ").to_string();
266    let core = s.trim_end_matches('Z');
267    if let Some(dot) = core.rfind('.') {
268        let head = &core[..dot];
269        let frac = core[dot + 1..].trim_end_matches('0');
270        if frac.is_empty() {
271            return Ok(format!("{}Z", head));
272        }
273        return Ok(format!("{}.{}Z", head, frac));
274    }
275    Ok(s)
276}