Skip to main content

structured_pkix/
temporal.rs

1//! UTC arithmetic with caller-supplied leap-second facts.
2//!
3//! UTC arithmetic uses no clock, network, built-in correction calendar or
4//! allocation. [`ExactReading`] additionally retains finer decimal precision
5//! in accounted storage. Applications authenticate and refresh correction
6//! facts; their declared coverage is never extended by assumption.
7
8const NANOS: i128 = 1_000_000_000;
9
10mod parse;
11mod precision;
12pub(crate) use parse::{admit_generalized, generalized};
13pub use parse::{read_generalized, read_generalized_exact};
14pub use precision::{CompareError, ExactReading};
15
16/// A parsed moment, retaining whether digits finer than a nanosecond remain.
17/// The upper endpoint cannot be rounded without UTC correction facts.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct Reading {
20    start: Instant,
21    subnanosecond: bool,
22}
23
24impl Reading {
25    /// The labelled nanosecond containing the moment, rounded down.
26    #[must_use]
27    pub const fn start(self) -> Instant {
28        self.start
29    }
30
31    /// Whether the represented moment is strictly later than `start`.
32    #[must_use]
33    pub const fn has_subnanosecond_remainder(self) -> bool {
34        self.subnanosecond
35    }
36
37    /// Validate the label and round upward using the supplied UTC facts.
38    pub fn upper_bound(self, table: &LeapTable<'_>) -> Result<Instant, Uncertainty> {
39        table.shift(self.start, i128::from(self.subnanosecond))
40    }
41}
42
43impl From<Instant> for Reading {
44    fn from(start: Instant) -> Self {
45        Self {
46            start,
47            subnanosecond: false,
48        }
49    }
50}
51
52/// A UTC label with nanosecond precision, preserving an inserted second.
53///
54/// Ordering is by UTC label, not by POSIX timestamp. Construction preserves
55/// a label; only a [`LeapTable`] can establish whether it occurred in UTC.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
57pub struct Instant {
58    seconds: i64,
59    leap: bool,
60    nanos: u32,
61}
62
63impl Instant {
64    /// Internal infallible construction of an ordinary whole-second label.
65    pub(crate) const fn whole(seconds: i64) -> Self {
66        Self {
67            seconds,
68            leap: false,
69            nanos: 0,
70        }
71    }
72
73    /// An ordinary POSIX-labelled second and its fractional nanoseconds.
74    #[must_use]
75    pub const fn new(seconds: i64, nanos: u32) -> Option<Self> {
76        if nanos >= 1_000_000_000 {
77            return None;
78        }
79        Some(Self {
80            seconds,
81            leap: false,
82            nanos,
83        })
84    }
85
86    /// The inserted second after `seconds`, which must label UTC 23:59:59.
87    /// This does not assert that an insertion actually occurred on that date.
88    #[must_use]
89    pub const fn leap_after(seconds: i64, nanos: u32) -> Option<Self> {
90        if seconds == i64::MAX || seconds.rem_euclid(86_400) != 86_399 || nanos >= 1_000_000_000 {
91            return None;
92        }
93        Some(Self {
94            seconds,
95            leap: true,
96            nanos,
97        })
98    }
99
100    /// The POSIX label, or the preceding second's label for a leap second.
101    /// Not an elapsed-seconds coordinate; use [`LeapTable::shift`] for arithmetic.
102    #[must_use]
103    pub const fn seconds(self) -> i64 {
104        self.seconds
105    }
106
107    /// Whether this labels the inserted second after [`Self::seconds`].
108    #[must_use]
109    pub const fn is_leap_second(self) -> bool {
110        self.leap
111    }
112
113    /// Nanoseconds within the labelled second, always less than one billion.
114    #[must_use]
115    pub const fn nanos(self) -> u32 {
116        self.nanos
117    }
118}
119
120/// A correction effective from a UTC midnight, expressed cumulatively.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct Transition {
123    at: i64,
124    correction: i32,
125}
126
127impl Transition {
128    /// `at` is the POSIX label of midnight after the changed day.
129    /// `correction` is the net inserted minus deleted seconds since coverage
130    /// began, not the absolute TAI-UTC offset. [`LeapTable::new`] validates it.
131    #[must_use]
132    pub const fn new(at: i64, correction: i32) -> Self {
133        Self { at, correction }
134    }
135
136    /// Midnight from which the new cumulative correction applies.
137    #[must_use]
138    pub const fn at(self) -> i64 {
139        self.at
140    }
141
142    /// Cumulative correction relative to the beginning of this table.
143    #[must_use]
144    pub const fn correction(self) -> i32 {
145        self.correction
146    }
147
148    fn linear(self) -> i128 {
149        i128::from(self.at) + i128::from(self.correction)
150    }
151}
152
153/// Why a supplied correction table cannot be used.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum InvalidTable {
156    /// Coverage is empty, reversed, or begins at a second deleted by a correction.
157    Coverage,
158    /// Transitions are unordered, duplicated, or outside the stated coverage.
159    Order,
160    /// A correction changes by something other than one second.
161    Correction,
162    /// A transition is not at the start of a UTC calendar month.
163    Boundary,
164}
165
166/// Why elapsed-time arithmetic cannot establish a result.
167/// This is not a signature-verification result.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum Uncertainty {
170    /// The source or destination is outside the caller's known interval.
171    OutsideCoverage,
172    /// The label contradicts the supplied UTC facts: an absent insertion or
173    /// an ordinary second removed by a negative correction.
174    NotInUtc,
175    /// The requested arithmetic exceeds the numeric representation.
176    OutOfRange,
177}
178
179/// Borrowed, validated UTC corrections for `[start, end)` POSIX labels.
180///
181/// The caller asserts that every correction in this interval is listed.
182/// Empty entries therefore mean no corrections *inside* the interval only.
183/// The table authenticates no source and makes no prediction outside it.
184#[derive(Debug, Clone, Copy)]
185pub struct LeapTable<'a> {
186    start: i64,
187    end: i64,
188    transitions: &'a [Transition],
189}
190
191impl<'a> LeapTable<'a> {
192    /// Validate the complete table once, without copying its entries.
193    /// A transition at `end` is included because it changes the preceding day.
194    pub fn new(start: i64, end: i64, transitions: &'a [Transition]) -> Result<Self, InvalidTable> {
195        if start >= end {
196            return Err(InvalidTable::Coverage);
197        }
198        let mut previous = start;
199        let mut correction = 0i32;
200        for entry in transitions {
201            if entry.at <= previous || entry.at > end {
202                return Err(InvalidTable::Order);
203            }
204            if (i64::from(entry.correction) - i64::from(correction)).abs() != 1 {
205                return Err(InvalidTable::Correction);
206            }
207            // RFC 3339 §5.7 and Appendix D describe positive and negative
208            // corrections at UTC month ends. This is a UTC-facts constraint,
209            // not an ASN.1 spelling rule: https://www.rfc-editor.org/rfc/rfc3339#section-5.7
210            if entry.at.rem_euclid(86_400) != 0 || super::time::civil_from_seconds(entry.at).2 != 1
211            {
212                return Err(InvalidTable::Boundary);
213            }
214            previous = entry.at;
215            correction = entry.correction;
216        }
217        let table = Self {
218            start,
219            end,
220            transitions,
221        };
222        if table
223            .coordinate(Instant {
224                seconds: start,
225                leap: false,
226                nanos: 0,
227            })
228            .is_err()
229        {
230            return Err(InvalidTable::Coverage);
231        }
232        Ok(table)
233    }
234
235    /// The exact borrowed correction entries used by this calculation.
236    #[must_use]
237    pub const fn transitions(&self) -> &'a [Transition] {
238        self.transitions
239    }
240
241    /// Move by elapsed nanoseconds, including inserted and deleted seconds.
242    /// Both endpoints must be covered; since coverage is contiguous, the
243    /// whole intervening interval is then covered as well.
244    ///
245    /// RFC 3161 §2.4.2 specifies subtracting/adding accuracy to `genTime`:
246    /// <https://www.rfc-editor.org/rfc/rfc3161#section-2.4.2>. The supplied
247    /// coverage and explicit uncertainty are API decisions, not RFC mandates.
248    pub fn shift(&self, at: Instant, nanos: i128) -> Result<Instant, Uncertainty> {
249        let here = self.coordinate(at)?;
250        let moved = here.checked_add(nanos).ok_or(Uncertainty::OutOfRange)?;
251        let second = moved.div_euclid(NANOS);
252        let fraction =
253            u32::try_from(moved.rem_euclid(NANOS)).map_err(|_| Uncertainty::OutOfRange)?;
254        let count = self
255            .transitions
256            .partition_point(|entry| entry.linear() <= second);
257        let correction = self.correction_before(count);
258        // A positive transition leaves one extra linear second immediately
259        // before midnight; preserve it rather than aliasing either neighbour.
260        let result = if let Some(next) = self.transitions.get(count)
261            && next.correction > correction
262            && second == next.linear() - 1
263        {
264            Instant {
265                seconds: next.at - 1,
266                leap: true,
267                nanos: fraction,
268            }
269        } else {
270            let seconds = i64::try_from(second - i128::from(correction))
271                .map_err(|_| Uncertainty::OutOfRange)?;
272            Instant {
273                seconds,
274                leap: false,
275                nanos: fraction,
276            }
277        };
278        self.coordinate(result)?;
279        Ok(result)
280    }
281
282    fn correction_before(&self, count: usize) -> i32 {
283        if count == 0 {
284            0
285        } else {
286            self.transitions[count - 1].correction
287        }
288    }
289
290    fn coordinate(&self, at: Instant) -> Result<i128, Uncertainty> {
291        if at.seconds < self.start || at.seconds >= self.end {
292            return Err(Uncertainty::OutsideCoverage);
293        }
294        let count = self
295            .transitions
296            .partition_point(|entry| entry.at <= at.seconds);
297        let correction = self.correction_before(count);
298        let next = self
299            .transitions
300            .get(count)
301            .filter(|entry| i128::from(entry.at) == i128::from(at.seconds) + 1);
302        if at.leap {
303            if next.is_none_or(|entry| entry.correction <= correction) {
304                return Err(Uncertainty::NotInUtc);
305            }
306        } else if next.is_some_and(|entry| entry.correction < correction) {
307            return Err(Uncertainty::NotInUtc);
308        }
309        Ok(
310            (i128::from(at.seconds) + i128::from(correction) + i128::from(at.leap)) * NANOS
311                + i128::from(at.nanos),
312        )
313    }
314}
315
316#[cfg(test)]
317mod tests;