Skip to main content

structured_pkix/
temporal.rs

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