Skip to main content

ntp_proto/
unix_time.rs

1use crate::protocol;
2#[cfg(feature = "std")]
3use std::time;
4
5/// The number of seconds from 1st January 1900 UTC to the start of the Unix epoch.
6pub const EPOCH_DELTA: i64 = 2_208_988_800;
7
8/// Error returned when creating an [`Instant`] with mixed-sign components.
9///
10/// Both `secs` and `subsec_nanos` must have the same sign (or be zero).
11/// A positive `secs` with negative `subsec_nanos` (or vice versa) is invalid.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct InvalidInstantError {
14    /// The seconds component that was passed.
15    pub secs: i64,
16    /// The sub-second nanoseconds component that was passed.
17    pub subsec_nanos: i32,
18}
19
20impl core::fmt::Display for InvalidInstantError {
21    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
22        write!(
23            f,
24            "invalid instant: secs ({}) and subsec_nanos ({}) have mixed signs",
25            self.secs, self.subsec_nanos
26        )
27    }
28}
29
30#[cfg(feature = "std")]
31impl std::error::Error for InvalidInstantError {}
32
33/// The number of seconds in one NTP era (2^32 seconds, approximately 136 years).
34///
35/// Era 0 spans from 1900-01-01 00:00:00 UTC to 2036-02-07 06:28:15 UTC.
36/// Era 1 begins at 2036-02-07 06:28:16 UTC.
37pub const ERA_SECONDS: i64 = 4_294_967_296; // 1i64 << 32
38
39// The NTP fractional scale (32-bit).
40const NTP_SCALE: f64 = u32::MAX as f64;
41
42// The NTP fractional scale (64-bit, for DateFormat).
43const NTP_SCALE_64: f64 = u64::MAX as f64;
44
45/// Describes an instant relative to the `UNIX_EPOCH` - 00:00:00 Coordinated Universal Time (UTC),
46/// Thursay, 1 January 1970 in seconds with the fractional part in nanoseconds.
47///
48/// If the **Instant** describes some moment prior to `UNIX_EPOCH`, both the `secs` and
49/// `subsec_nanos` components will be negative.
50///
51/// The sole purpose of this type is for retrieving the "current" time using the `std::time` module
52/// and for converting between the ntp timestamp formats. If you are interested in converting from
53/// unix time to some other more human readable format, perhaps see the [chrono
54/// crate](https://crates.io/crates/chrono).
55///
56/// ## Example
57///
58/// Here is a demonstration of displaying the **Instant** in local time using the chrono crate
59/// (requires the `std` feature):
60///
61/// ```ignore
62/// use chrono::TimeZone;
63///
64/// let unix_time = ntp_proto::unix_time::Instant::now();
65/// let local_time = chrono::Local.timestamp(unix_time.secs(), unix_time.subsec_nanos() as _);
66/// println!("{}", local_time);
67/// ```
68#[derive(Copy, Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct Instant {
70    secs: i64,
71    subsec_nanos: i32,
72}
73
74impl Instant {
75    /// Create a new **Instant** given its `secs` and `subsec_nanos` components.
76    ///
77    /// To indicate a time following `UNIX_EPOCH`, both `secs` and `subsec_nanos` must be positive.
78    /// To indicate a time prior to `UNIX_EPOCH`, both `secs` and `subsec_nanos` must be negative.
79    /// Returns [`InvalidInstantError`] if the signs are mixed.
80    pub fn new(secs: i64, subsec_nanos: i32) -> Result<Instant, InvalidInstantError> {
81        if (secs > 0 && subsec_nanos < 0) || (secs < 0 && subsec_nanos > 0) {
82            return Err(InvalidInstantError { secs, subsec_nanos });
83        }
84        Ok(Instant { secs, subsec_nanos })
85    }
86
87    /// Uses `std::time::SystemTime::now` and `std::time::UNIX_EPOCH` to determine the current
88    /// **Instant**.
89    ///
90    /// ## Example
91    ///
92    /// ```
93    /// println!("{:?}", ntp_proto::unix_time::Instant::now());
94    /// ```
95    #[cfg(feature = "std")]
96    pub fn now() -> Self {
97        match time::SystemTime::now().duration_since(time::UNIX_EPOCH) {
98            Ok(duration) => {
99                let secs = duration.as_secs() as i64;
100                let subsec_nanos = duration.subsec_nanos() as i32;
101                Instant::new(secs, subsec_nanos)
102                    .expect("system clock produces same-sign components")
103            }
104            Err(sys_time_err) => {
105                let duration_pre_unix_epoch = sys_time_err.duration();
106                let secs = -(duration_pre_unix_epoch.as_secs() as i64);
107                let subsec_nanos = -(duration_pre_unix_epoch.subsec_nanos() as i32);
108                Instant::new(secs, subsec_nanos)
109                    .expect("system clock produces same-sign components")
110            }
111        }
112    }
113
114    /// The "seconds" component of the **Instant**.
115    pub fn secs(&self) -> i64 {
116        self.secs
117    }
118
119    /// The fractional component of the **Instant** in nanoseconds.
120    pub fn subsec_nanos(&self) -> i32 {
121        self.subsec_nanos
122    }
123}
124
125// Era-aware conversion helpers.
126
127/// Given a raw 32-bit NTP timestamp seconds value and a pivot `Instant`,
128/// return the absolute NTP seconds (i64) by selecting the era closest to the pivot.
129///
130/// The algorithm assumes the timestamp is within half an era (~68 years) of the pivot.
131fn era_aware_ntp_seconds(raw_seconds: u32, pivot: &Instant) -> i64 {
132    let pivot_ntp = pivot.secs + EPOCH_DELTA;
133    let raw = raw_seconds as i64;
134
135    // Candidate in the same era as the pivot.
136    let pivot_era = pivot_ntp.div_euclid(ERA_SECONDS);
137    let candidate = pivot_era * ERA_SECONDS + raw;
138
139    // Check if the candidate is within half an era of the pivot.
140    // If not, try the adjacent era.
141    let diff = candidate - pivot_ntp;
142    if diff > ERA_SECONDS / 2 {
143        candidate - ERA_SECONDS
144    } else if diff < -(ERA_SECONDS / 2) {
145        candidate + ERA_SECONDS
146    } else {
147        candidate
148    }
149}
150
151/// Convert a [`protocol::TimestampFormat`] to an [`Instant`] using the given pivot
152/// for era disambiguation.
153///
154/// The 32-bit NTP timestamp format is ambiguous across eras (each era spans ~136 years).
155/// This function resolves the ambiguity by selecting the era that places the timestamp
156/// closest to the provided pivot (within ~68 years).
157///
158/// For live NTP usage, pass `Instant::now()` as the pivot. For offline or replay
159/// scenarios, pass a known reference time.
160pub fn timestamp_to_instant(ts: protocol::TimestampFormat, pivot: &Instant) -> Instant {
161    let ntp_secs = era_aware_ntp_seconds(ts.seconds, pivot);
162    let secs = ntp_secs - EPOCH_DELTA;
163    let subsec_nanos = (ts.fraction as f64 / NTP_SCALE * 1e9) as i32;
164    Instant::new(secs, subsec_nanos).expect("era-aware conversion produces same-sign components")
165}
166
167// Conversion implementations.
168
169impl From<protocol::ShortFormat> for Instant {
170    fn from(t: protocol::ShortFormat) -> Self {
171        let secs = t.seconds as i64 - EPOCH_DELTA;
172        let subsec_nanos = (t.fraction as f64 / NTP_SCALE * 1e9) as i32;
173        Instant::new(secs, subsec_nanos).expect("ShortFormat produces same-sign components")
174    }
175}
176
177#[cfg(feature = "std")]
178impl From<protocol::TimestampFormat> for Instant {
179    /// Converts a 32-bit NTP timestamp to a Unix [`Instant`], using the current system
180    /// time as a pivot for era disambiguation.
181    ///
182    /// This is correct for live NTP usage where timestamps are close to "now".
183    /// For offline or replay scenarios, use [`timestamp_to_instant`] with an explicit pivot.
184    fn from(t: protocol::TimestampFormat) -> Self {
185        timestamp_to_instant(t, &Instant::now())
186    }
187}
188
189impl From<Instant> for protocol::ShortFormat {
190    fn from(t: Instant) -> Self {
191        let sec = t.secs() + EPOCH_DELTA;
192        let frac = t.subsec_nanos() as f64 * NTP_SCALE / 1e9;
193        protocol::ShortFormat {
194            seconds: sec as u16,
195            fraction: frac as u16,
196        }
197    }
198}
199
200impl From<Instant> for protocol::TimestampFormat {
201    /// Converts a Unix [`Instant`] to a 32-bit NTP timestamp.
202    ///
203    /// **Note**: This truncates to 32 bits, losing era information. The resulting
204    /// [`protocol::TimestampFormat`] is correct for NTPv4 on-wire use, but the era must
205    /// be inferred by the receiver using a pivot-based approach (see [`timestamp_to_instant`]).
206    fn from(t: Instant) -> Self {
207        let sec = t.secs() + EPOCH_DELTA;
208        let frac = t.subsec_nanos() as f64 * NTP_SCALE / 1e9;
209        protocol::TimestampFormat {
210            seconds: sec as u32,
211            fraction: frac as u32,
212        }
213    }
214}
215
216impl From<protocol::DateFormat> for Instant {
217    /// Converts a 128-bit NTP date format (with explicit era) to a Unix [`Instant`].
218    ///
219    /// This conversion is unambiguous because [`protocol::DateFormat`] includes the era number.
220    fn from(d: protocol::DateFormat) -> Self {
221        let ntp_secs = d.era_number as i64 * ERA_SECONDS + d.era_offset as i64;
222        let secs = ntp_secs - EPOCH_DELTA;
223        let subsec_nanos = (d.fraction as f64 / NTP_SCALE_64 * 1e9) as i32;
224        Instant::new(secs, subsec_nanos).expect("DateFormat produces same-sign components")
225    }
226}
227
228impl From<Instant> for protocol::DateFormat {
229    /// Converts a Unix [`Instant`] to a 128-bit NTP date format with explicit era.
230    ///
231    /// This conversion preserves era information and is unambiguous.
232    fn from(t: Instant) -> Self {
233        let ntp_secs = t.secs() + EPOCH_DELTA;
234        let era_number = ntp_secs.div_euclid(ERA_SECONDS) as i32;
235        let era_offset = ntp_secs.rem_euclid(ERA_SECONDS) as u32;
236        let fraction = (t.subsec_nanos().unsigned_abs() as f64 / 1e9 * NTP_SCALE_64) as u64;
237        protocol::DateFormat {
238            era_number,
239            era_offset,
240            fraction,
241        }
242    }
243}
244
245#[cfg(all(test, feature = "std"))]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn era0_timestamp_to_instant() {
251        // 2024-01-01 00:00:00 UTC: Unix=1704067200, NTP=3913056000
252        let ts = protocol::TimestampFormat {
253            seconds: 3_913_056_000,
254            fraction: 0,
255        };
256        let pivot = Instant::new(1_704_067_200, 0).unwrap();
257        let result = timestamp_to_instant(ts, &pivot);
258        assert_eq!(result.secs(), 1_704_067_200);
259    }
260
261    #[test]
262    fn era1_timestamp_with_era1_pivot() {
263        // Era 1, offset 100_000_000 => absolute NTP = 2^32 + 100_000_000
264        // Unix = 4_294_967_296 + 100_000_000 - 2_208_988_800 = 2_185_978_496
265        let ts = protocol::TimestampFormat {
266            seconds: 100_000_000,
267            fraction: 0,
268        };
269        let pivot = Instant::new(2_185_978_496, 0).unwrap();
270        let result = timestamp_to_instant(ts, &pivot);
271        assert_eq!(result.secs(), 2_185_978_496);
272    }
273
274    #[test]
275    fn era_boundary_pivot_before_ts_after() {
276        // Pivot in Jan 2036 (Era 0). Timestamp NTP=1000 should resolve to Era 1.
277        let pivot = Instant::new(2_082_758_400, 0).unwrap(); // ~2036-01-01
278        let ts = protocol::TimestampFormat {
279            seconds: 1000,
280            fraction: 0,
281        };
282        let result = timestamp_to_instant(ts, &pivot);
283        let expected = ERA_SECONDS + 1000 - EPOCH_DELTA;
284        assert_eq!(result.secs(), expected);
285    }
286
287    #[test]
288    fn era_boundary_pivot_after_ts_before() {
289        // Pivot in Mar 2036 (Era 1). Timestamp near u32::MAX should resolve to Era 0.
290        let pivot = Instant::new(2_087_942_400, 0).unwrap(); // ~2036-03-01
291        let ts = protocol::TimestampFormat {
292            seconds: u32::MAX,
293            fraction: 0,
294        };
295        let result = timestamp_to_instant(ts, &pivot);
296        let expected = u32::MAX as i64 - EPOCH_DELTA;
297        assert_eq!(result.secs(), expected);
298    }
299
300    #[test]
301    fn date_format_roundtrip_era0() {
302        let instant = Instant::new(1_704_067_200, 500_000_000).unwrap();
303        let date: protocol::DateFormat = instant.into();
304        assert_eq!(date.era_number, 0);
305        let back: Instant = date.into();
306        assert_eq!(back.secs(), instant.secs());
307        assert!((back.subsec_nanos() - instant.subsec_nanos()).abs() <= 1);
308    }
309
310    #[test]
311    fn date_format_roundtrip_era1() {
312        let instant = Instant::new(2_185_978_496, 0).unwrap(); // ~2039
313        let date: protocol::DateFormat = instant.into();
314        assert_eq!(date.era_number, 1);
315        let back: Instant = date.into();
316        assert_eq!(back.secs(), instant.secs());
317    }
318
319    #[test]
320    fn timestamp_format_roundtrip_with_pivot() {
321        let original = Instant::new(1_704_067_200, 0).unwrap();
322        let ts: protocol::TimestampFormat = original.into();
323        let restored = timestamp_to_instant(ts, &original);
324        assert_eq!(restored.secs(), original.secs());
325    }
326
327    #[test]
328    fn date_format_negative_era() {
329        // A time before 1900 => era -1
330        let instant = Instant::new(-2_300_000_000, 0).unwrap();
331        let date: protocol::DateFormat = instant.into();
332        assert_eq!(date.era_number, -1);
333        let back: Instant = date.into();
334        assert_eq!(back.secs(), instant.secs());
335    }
336}