Skip to main content

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