dash_runtime/timing/
datetime.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
32#[non_exhaustive]
33pub enum DateTimeError {
34 #[error("not an accepted xs:dateTime: {0}")]
36 Syntax(&'static str),
37 #[error("xs:dateTime field out of range: {0}")]
40 Field(&'static str),
41 #[error("instant outside the representable range")]
43 OutOfRange,
44 #[error("not an HTTP Date value")]
46 HttpDate,
47 #[error("not an 8-byte NTP timestamp")]
49 Ntp,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct XsDateTime {
56 pub unix_us: i64,
59 pub fraction_digits: u8,
61}
62
63impl XsDateTime {
64 #[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
120pub 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
236pub 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
261pub 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}