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