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