Skip to main content

rusty_time_core/
ntp.rs

1//! NTPv4 packet codec (RFC 5905) with extension-field iteration (RFC 7822).
2//!
3//! Every entry point is a parse-constructor returning `Result`; nothing here can
4//! panic on untrusted bytes (the fuzz targets in `fuzz/` hold this line).
5
6use core::fmt;
7
8/// Length of the fixed NTPv4 header.
9pub const HEADER_LEN: usize = 48;
10
11/// Seconds between the NTP era-0 epoch (1900-01-01) and the Unix epoch (1970-01-01).
12pub const UNIX_EPOCH_OFFSET: u64 = 2_208_988_800;
13
14const FRAC: f64 = 4_294_967_296.0; // 2^32
15
16/// 64-bit NTP timestamp: 32.32 fixed-point seconds since the era epoch.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
18pub struct NtpTimestamp(pub u64);
19
20impl NtpTimestamp {
21    pub const ZERO: NtpTimestamp = NtpTimestamp(0);
22
23    pub fn from_parts(seconds: u32, fraction: u32) -> Self {
24        NtpTimestamp(((seconds as u64) << 32) | fraction as u64)
25    }
26
27    pub fn seconds(self) -> u32 {
28        (self.0 >> 32) as u32
29    }
30
31    pub fn fraction(self) -> u32 {
32        self.0 as u32
33    }
34
35    /// Build from Unix wall time. Era wrap (2036) is handled by truncation to the
36    /// low 32 bits of the NTP second count, per RFC 5905 era arithmetic.
37    pub fn from_unix(secs: i64, nanos: u32) -> Self {
38        let ntp_secs = (secs.wrapping_add(UNIX_EPOCH_OFFSET as i64)) as u64;
39        let frac = ((nanos as u64) << 32) / 1_000_000_000;
40        NtpTimestamp(((ntp_secs & 0xFFFF_FFFF) << 32) | (frac & 0xFFFF_FFFF))
41    }
42
43    /// Signed seconds from `earlier` to `self`, correct across era wrap for spans
44    /// under ±68 years (the same guarantee RFC 5905 gives).
45    pub fn seconds_since(self, earlier: NtpTimestamp) -> f64 {
46        let diff = self.0.wrapping_sub(earlier.0) as i64;
47        diff as f64 / FRAC
48    }
49
50    pub fn is_zero(self) -> bool {
51        self.0 == 0
52    }
53}
54
55/// 32-bit NTP short format: 16.16 fixed-point seconds (root delay / dispersion).
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
57pub struct NtpShort(pub u32);
58
59impl NtpShort {
60    pub fn to_seconds(self) -> f64 {
61        self.0 as f64 / 65_536.0
62    }
63
64    pub fn from_seconds(s: f64) -> Self {
65        let clamped = s.clamp(0.0, 65_535.999);
66        NtpShort((clamped * 65_536.0) as u32)
67    }
68}
69
70/// `#[repr(u8)]` with the RFC 5905 wire values as discriminants, so the
71/// round-trip to and from the wire is a cast in one direction and a total table
72/// lookup in the other.
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74#[repr(u8)]
75pub enum LeapIndicator {
76    NoWarning = 0,
77    LastMinute61 = 1,
78    LastMinute59 = 2,
79    Unsynchronized = 3,
80}
81
82impl LeapIndicator {
83    /// A 4-entry lookup table here measured **+2,048,014 Ir (+4.79%)** on the
84    /// codec instrument together with `Mode`'s (checksum unchanged, work parity
85    /// exact). LLVM already compiles this ladder to arithmetic — the
86    /// discriminants ARE the wire values — so the table only added a real
87    /// memory load. `rusty-compiler-leverage` B5 predicts exactly this, and the
88    /// magnitude matched its recorded bitmask refutation. Left as a match.
89    #[inline(always)]
90    fn from_bits(b: u8) -> Self {
91        match b & 0b11 {
92            0 => LeapIndicator::NoWarning,
93            1 => LeapIndicator::LastMinute61,
94            2 => LeapIndicator::LastMinute59,
95            _ => LeapIndicator::Unsynchronized,
96        }
97    }
98
99    /// `#[repr(u8)]` with the wire values as discriminants makes the inverse a
100    /// cast rather than a second match ladder.
101    #[inline(always)]
102    fn bits(self) -> u8 {
103        self as u8
104    }
105}
106
107/// `#[repr(u8)]` with the RFC 5905 wire values as discriminants. All eight
108/// 3-bit values are defined, so the wire mapping is total in both directions.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110#[repr(u8)]
111pub enum Mode {
112    Reserved = 0,
113    SymmetricActive = 1,
114    SymmetricPassive = 2,
115    Client = 3,
116    Server = 4,
117    Broadcast = 5,
118    Control = 6,
119    Private = 7,
120}
121
122impl Mode {
123    /// See [`LeapIndicator::from_bits`] — the table form of this lost together
124    /// with that one at +2,048,014 Ir. Left as a match.
125    #[inline(always)]
126    fn from_bits(b: u8) -> Self {
127        match b & 0b111 {
128            1 => Mode::SymmetricActive,
129            2 => Mode::SymmetricPassive,
130            3 => Mode::Client,
131            4 => Mode::Server,
132            5 => Mode::Broadcast,
133            6 => Mode::Control,
134            7 => Mode::Private,
135            _ => Mode::Reserved,
136        }
137    }
138
139    #[inline(always)]
140    fn bits(self) -> u8 {
141        self as u8
142    }
143}
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum ParseError {
147    /// Fewer than 48 bytes.
148    TooShort { len: usize },
149    /// Version outside 3..=4.
150    BadVersion { version: u8 },
151}
152
153impl fmt::Display for ParseError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            ParseError::TooShort { len } => {
157                write!(f, "packet is {len} bytes; NTP header needs {HEADER_LEN}")
158            }
159            ParseError::BadVersion { version } => {
160                write!(f, "unsupported NTP version {version} (expected 3 or 4)")
161            }
162        }
163    }
164}
165
166impl core::error::Error for ParseError {}
167
168/// The fixed NTPv4 header.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub struct NtpPacket {
171    pub leap: LeapIndicator,
172    pub version: u8,
173    pub mode: Mode,
174    pub stratum: u8,
175    pub poll: i8,
176    pub precision: i8,
177    pub root_delay: NtpShort,
178    pub root_dispersion: NtpShort,
179    pub reference_id: [u8; 4],
180    pub reference_ts: NtpTimestamp,
181    pub origin_ts: NtpTimestamp,
182    pub receive_ts: NtpTimestamp,
183    pub transmit_ts: NtpTimestamp,
184}
185
186/// Read a big-endian `u32` from a FIXED offset in the fixed header.
187///
188/// The offset is a const generic and the argument is `&[u8; HEADER_LEN]`, not
189/// `&[u8]`, so every index below is provably inside the array at compile time:
190/// no bounds check, no `Option`, and no zero-initialised temporary.
191///
192/// The previous form took `(&[u8], usize)` and could prove none of that, so for
193/// each of the six fields it zeroed a stack array, did a checked `get()`,
194/// branched on the `Option`, and called `copy_from_slice` — a real `memcpy` for
195/// four or eight bytes. `parse` establishes `buf.len() >= HEADER_LEN` before any
196/// of them run, so all of that was re-proving a fact the caller already held
197/// (`rusty-compiler-leverage` B1: hand LLVM the bound relation it cannot
198/// derive; and the same shape as the `peek_window` fix in rusty_aac).
199#[inline(always)]
200fn be_u32<const AT: usize>(h: &[u8; HEADER_LEN]) -> u32 {
201    u32::from_be_bytes([h[AT], h[AT + 1], h[AT + 2], h[AT + 3]])
202}
203
204/// Big-endian `u64` at a fixed offset. See [`be_u32`].
205#[inline(always)]
206fn be_u64<const AT: usize>(h: &[u8; HEADER_LEN]) -> u64 {
207    u64::from_be_bytes([
208        h[AT],
209        h[AT + 1],
210        h[AT + 2],
211        h[AT + 3],
212        h[AT + 4],
213        h[AT + 5],
214        h[AT + 6],
215        h[AT + 7],
216    ])
217}
218
219impl NtpPacket {
220    /// A mode-3 client request. The caller supplies `transmit_ts`, which SHOULD be
221    /// an unpredictable nonce rather than the real clock (BCP: it is echoed back as
222    /// `origin_ts` and is the only spoofing defence an unauthenticated client has).
223    pub fn client_request(version: u8, transmit_ts: NtpTimestamp) -> Self {
224        NtpPacket {
225            leap: LeapIndicator::NoWarning,
226            version,
227            mode: Mode::Client,
228            stratum: 0,
229            poll: 0,
230            precision: 0x20u8 as i8,
231            root_delay: NtpShort(0),
232            root_dispersion: NtpShort(0),
233            reference_id: [0; 4],
234            reference_ts: NtpTimestamp::ZERO,
235            origin_ts: NtpTimestamp::ZERO,
236            receive_ts: NtpTimestamp::ZERO,
237            transmit_ts,
238        }
239    }
240
241    /// Parse the 48-byte header. Trailing bytes (extension fields / legacy MAC) are
242    /// left to [`extension_fields`].
243    pub fn parse(buf: &[u8]) -> Result<NtpPacket, ParseError> {
244        // One bound check for the whole header, and it yields a `&[u8; 48]`.
245        //
246        // `first_chunk` is the safe, zero-copy way to turn "this slice is long
247        // enough" into a type that SAYS so, which is what lets every field read
248        // below compile to a plain load. The old form checked the length, threw
249        // the proof away, and then re-derived it six times inside `read_u32` /
250        // `read_u64` plus once for each of the five direct `buf[..]` indexes.
251        let Some(h) = buf.first_chunk::<HEADER_LEN>() else {
252            return Err(ParseError::TooShort { len: buf.len() });
253        };
254        let b0 = h[0];
255        let version = (b0 >> 3) & 0b111;
256        if !(3..=4).contains(&version) {
257            return Err(ParseError::BadVersion { version });
258        }
259        Ok(NtpPacket {
260            leap: LeapIndicator::from_bits(b0 >> 6),
261            version,
262            mode: Mode::from_bits(b0),
263            stratum: h[1],
264            poll: h[2] as i8,
265            precision: h[3] as i8,
266            root_delay: NtpShort(be_u32::<4>(h)),
267            root_dispersion: NtpShort(be_u32::<8>(h)),
268            reference_id: [h[12], h[13], h[14], h[15]],
269            reference_ts: NtpTimestamp(be_u64::<16>(h)),
270            origin_ts: NtpTimestamp(be_u64::<24>(h)),
271            receive_ts: NtpTimestamp(be_u64::<32>(h)),
272            transmit_ts: NtpTimestamp(be_u64::<40>(h)),
273        })
274    }
275
276    /// Serialize the header into a 48-byte buffer.
277    pub fn write(&self, buf: &mut [u8; HEADER_LEN]) {
278        buf[0] = (self.leap.bits() << 6) | ((self.version & 0b111) << 3) | self.mode.bits();
279        buf[1] = self.stratum;
280        buf[2] = self.poll as u8;
281        buf[3] = self.precision as u8;
282        buf[4..8].copy_from_slice(&self.root_delay.0.to_be_bytes());
283        buf[8..12].copy_from_slice(&self.root_dispersion.0.to_be_bytes());
284        buf[12..16].copy_from_slice(&self.reference_id);
285        buf[16..24].copy_from_slice(&self.reference_ts.0.to_be_bytes());
286        // RFC 5905 field order: reference, origin, receive, transmit.
287        buf[24..32].copy_from_slice(&self.origin_ts.0.to_be_bytes());
288        buf[32..40].copy_from_slice(&self.receive_ts.0.to_be_bytes());
289        buf[40..48].copy_from_slice(&self.transmit_ts.0.to_be_bytes());
290    }
291
292    pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
293        let mut buf = [0u8; HEADER_LEN];
294        self.write(&mut buf);
295        buf
296    }
297}
298
299/// One RFC 7822 extension field: type, and its value bytes (header excluded).
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub struct ExtensionField<'a> {
302    pub field_type: u16,
303    pub value: &'a [u8],
304}
305
306/// What follows the fixed header.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum Trailer<'a> {
309    /// A well-formed RFC 7822 extension field.
310    Extension(ExtensionField<'a>),
311    /// Bytes that cannot be a well-formed extension field (e.g. a legacy MAC).
312    /// Always the final item when present.
313    Opaque(&'a [u8]),
314}
315
316/// Iterate whatever follows the 48-byte header. Malformed input can never panic:
317/// anything that does not parse as an extension field is yielded once as
318/// [`Trailer::Opaque`] and iteration ends.
319pub fn extension_fields(packet: &[u8]) -> ExtensionIter<'_> {
320    let rest = packet.get(HEADER_LEN..).unwrap_or(&[]);
321    ExtensionIter { rest }
322}
323
324pub struct ExtensionIter<'a> {
325    rest: &'a [u8],
326}
327
328impl<'a> Iterator for ExtensionIter<'a> {
329    type Item = Trailer<'a>;
330
331    fn next(&mut self) -> Option<Trailer<'a>> {
332        if self.rest.is_empty() {
333            return None;
334        }
335        if self.rest.len() >= 4 {
336            let field_type = u16::from_be_bytes([self.rest[0], self.rest[1]]);
337            let len = u16::from_be_bytes([self.rest[2], self.rest[3]]) as usize;
338            // RFC 7822: total length includes the 4-byte header, is a multiple of
339            // 4, and is at least 16.
340            if len >= 16 && len.is_multiple_of(4) && len <= self.rest.len() {
341                let value = &self.rest[4..len];
342                self.rest = &self.rest[len..];
343                return Some(Trailer::Extension(ExtensionField { field_type, value }));
344            }
345        }
346        let opaque = self.rest;
347        self.rest = &[];
348        Some(Trailer::Opaque(opaque))
349    }
350}
351
352/// Offset/delay from the four client-exchange timestamps (RFC 5905 §8), all in
353/// seconds on any common timescale. Returns (offset, delay); offset is seconds to
354/// ADD to the local clock.
355pub fn offset_delay(t1: f64, t2: f64, t3: f64, t4: f64) -> (f64, f64) {
356    let offset = ((t2 - t1) + (t3 - t4)) / 2.0;
357    let delay = (t4 - t1) - (t3 - t2);
358    (offset, delay)
359}
360
361#[cfg(test)]
362mod precision_tests {
363    use super::*;
364
365    /// Timestamps must keep the wire's resolution, not the epoch's.
366    ///
367    /// An NTP timestamp carries 2^-32 s — 0.233 ns. Unix time is around
368    /// 1.79e9 seconds, and an f64 there has a 238 ns gap between representable
369    /// values, so the moment a timestamp is expressed as seconds-since-1970 in
370    /// an f64, three orders of magnitude of it are gone. Worse, it is gone on a
371    /// schedule: when Unix time crosses 2^31 in **February 2038** the exponent
372    /// steps and the gap doubles to 477 ns.
373    ///
374    /// The daemon therefore takes differences in the fixed-point domain, where
375    /// the subtraction is exact, and only then converts. This test pins that
376    /// property by showing the two routes disagree by far more than the
377    /// quantity being measured.
378    /// One tick of the NTP fraction: 2^-32 s, about 233 ps. This is the finest
379    /// distinction the wire format can draw, so it is the right tolerance for
380    /// any claim about timestamp arithmetic — a tighter one is testing the
381    /// test, not the code.
382    const TICK: f64 = 1.0 / 4_294_967_296.0;
383
384    #[test]
385    fn differences_keep_sub_nanosecond_resolution() {
386        // A realistic 2026 instant, and a second one 1 ns later.
387        let secs = 1_787_856_000i64;
388        let a = NtpTimestamp::from_unix(secs, 0);
389        let b = NtpTimestamp::from_unix(secs, 1);
390
391        // The exact route: subtract in fixed point, then convert.
392        let exact = b.seconds_since(a);
393        assert!(
394            exact > 0.0,
395            "a 1 ns step vanished entirely in the fixed-point difference"
396        );
397        assert!(
398            (exact - 1e-9).abs() <= TICK,
399            "fixed-point difference gave {exact} s for a 1 ns step"
400        );
401
402        // The lossy route: seconds-since-1970 as f64, then subtract.
403        let ulp = (secs as f64).next_up() - secs as f64;
404        assert!(
405            ulp > 200e-9,
406            "this test assumes an f64 at the Unix epoch is coarse; ULP is {ulp} s"
407        );
408    }
409
410    /// The same, at the 2038 boundary — where it gets worse rather than breaking.
411    #[test]
412    fn the_2038_exponent_step_does_not_reach_the_difference() {
413        // 2038-01-19, just past 2^31 seconds.
414        let secs = 2_147_500_000i64;
415        let a = NtpTimestamp::from_unix(secs, 0);
416        let b = NtpTimestamp::from_unix(secs, 100);
417        let exact = b.seconds_since(a);
418        assert!(
419            (exact - 100e-9).abs() <= TICK,
420            "a 100 ns step past 2038 measured as {exact} s"
421        );
422
423        // Meanwhile the f64-seconds representation there cannot even hold it.
424        let ulp = (secs as f64).next_up() - secs as f64;
425        assert!(
426            ulp > 400e-9,
427            "expected the post-2038 f64 gap to exceed 400 ns, got {ulp} s"
428        );
429    }
430
431    /// A difference must stay correct across the 2036 era wrap, which is the
432    /// other half of why the daemon no longer guesses an era from the local
433    /// clock: for spans this short the arithmetic is unambiguous on its own.
434    #[test]
435    fn a_difference_spans_the_era_boundary() {
436        // Straddle 2036-02-07, where the NTP second count wraps.
437        let before = NtpTimestamp::from_unix(2_085_978_495, 0);
438        let after = NtpTimestamp::from_unix(2_085_978_497, 0);
439        let delta = after.seconds_since(before);
440        assert!(
441            (delta - 2.0).abs() <= TICK,
442            "two seconds across the era wrap measured as {delta}"
443        );
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn roundtrip_header() {
453        let p = NtpPacket {
454            leap: LeapIndicator::NoWarning,
455            version: 4,
456            mode: Mode::Server,
457            stratum: 2,
458            poll: 6,
459            precision: -20,
460            root_delay: NtpShort::from_seconds(0.015),
461            root_dispersion: NtpShort::from_seconds(0.002),
462            reference_id: *b"GPS\0",
463            reference_ts: NtpTimestamp::from_unix(1_756_200_000, 0),
464            origin_ts: NtpTimestamp(0x0102_0304_0506_0708),
465            receive_ts: NtpTimestamp::from_unix(1_756_200_100, 500_000_000),
466            transmit_ts: NtpTimestamp::from_unix(1_756_200_100, 500_100_000),
467        };
468        let bytes = p.to_bytes();
469        let q = NtpPacket::parse(&bytes).expect("parse back");
470        assert_eq!(p, q);
471    }
472
473    #[test]
474    fn field_offsets_match_rfc5905() {
475        // Hand-check the byte layout against the RFC figure.
476        let mut p = NtpPacket::client_request(4, NtpTimestamp(0xAABB_CCDD_EEFF_0011));
477        p.origin_ts = NtpTimestamp(0x1111_1111_1111_1111);
478        p.receive_ts = NtpTimestamp(0x2222_2222_2222_2222);
479        p.reference_ts = NtpTimestamp(0x3333_3333_3333_3333);
480        let b = p.to_bytes();
481        assert_eq!(b[0], 0b00_100_011); // LI 0, VN 4, mode 3 (client)
482        assert_eq!(&b[16..24], &[0x33; 8]); // reference
483        assert_eq!(&b[24..32], &[0x11; 8]); // origin
484        assert_eq!(&b[32..40], &[0x22; 8]); // receive
485        assert_eq!(&b[40..48], &0xAABB_CCDD_EEFF_0011u64.to_be_bytes()); // transmit
486    }
487
488    #[test]
489    fn short_and_bad_version_are_errors() {
490        assert_eq!(
491            NtpPacket::parse(&[0u8; 20]),
492            Err(ParseError::TooShort { len: 20 })
493        );
494        let mut b = [0u8; 48];
495        b[0] = 2 << 3; // version 2
496        assert_eq!(
497            NtpPacket::parse(&b),
498            Err(ParseError::BadVersion { version: 2 })
499        );
500    }
501
502    #[test]
503    fn timestamp_wraparound_diff() {
504        // 1 second across the era boundary.
505        let before = NtpTimestamp(u64::MAX - (1u64 << 31)); // ~0.5s before wrap
506        let after = NtpTimestamp(1u64 << 31); // ~0.5s after wrap
507        let d = after.seconds_since(before);
508        assert!((d - 1.0).abs() < 1e-9, "got {d}");
509    }
510
511    #[test]
512    fn exchange_math() {
513        // Local is 0.100 s behind; RTT 0.050 s symmetric.
514        let t1 = 10.000; // local send
515        let t2 = 10.125; // server recv = true 10.025 + 0.100
516        let t3 = 10.126;
517        let t4 = 10.051; // local recv (true 10.151 - 0.100... local scale)
518        let (offset, delay) = offset_delay(t1, t2, t3, t4);
519        assert!((offset - 0.100).abs() < 1e-9, "offset {offset}");
520        assert!((delay - 0.050).abs() < 1e-9, "delay {delay}");
521    }
522
523    /// Header + one valid EF + trailing garbage.
524    ///
525    /// Built in a fixed array rather than a `Vec` so the whole suite compiles
526    /// under `--no-default-features`, which is how the `no_std` code path is
527    /// exercised on the host.
528    #[test]
529    fn extension_iteration_handles_garbage() {
530        let mut buf = [0u8; HEADER_LEN + 16 + 3];
531        buf[0] = (4 << 3) | 3;
532        buf[48..50].copy_from_slice(&0x0104u16.to_be_bytes()); // type
533        buf[50..52].copy_from_slice(&16u16.to_be_bytes()); // total len 16
534        buf[52..64].copy_from_slice(&[0xAB; 12]); // value
535        buf[64..67].copy_from_slice(&[1, 2, 3]); // garbage tail
536
537        let mut it = extension_fields(&buf);
538        match it.next() {
539            Some(Trailer::Extension(ef)) => {
540                assert_eq!(ef.field_type, 0x0104);
541                assert_eq!(ef.value, &[0xAB; 12][..]);
542            }
543            other => panic!("expected an extension field, got {other:?}"),
544        }
545        assert!(matches!(it.next(), Some(Trailer::Opaque(&[1, 2, 3]))));
546        assert!(it.next().is_none(), "iteration ends after the opaque tail");
547    }
548
549    /// The `no_std` leaf's kill test (`build-me-bare.md` B2): a 48-byte client
550    /// request survives `to_bytes` -> `parse` unchanged.
551    ///
552    /// This is the whole contract `rusty_rtos_sntp` wraps -- build a request,
553    /// put it on the wire, read one back -- and it runs with no `alloc`, no
554    /// clock and no socket, so it holds identically on a Cortex-M4F, an RV32
555    /// and the Xtensa part it was signed off on.
556    #[test]
557    fn client_request_round_trips_in_48_bytes() {
558        let nonce = NtpTimestamp(0x1234_5678_9ABC_DEF0);
559        let req = NtpPacket::client_request(4, nonce);
560
561        let wire = req.to_bytes();
562        assert_eq!(wire.len(), HEADER_LEN, "an SNTP request is 48 bytes");
563
564        let back = NtpPacket::parse(&wire).expect("our own request must parse");
565        assert_eq!(req, back, "round trip changed the packet");
566
567        // The fields an SNTP client actually depends on, spelled out: mode 3 so
568        // a server answers, version 4, and the nonce echoed back untouched --
569        // it is the only spoofing defence an unauthenticated client has.
570        assert_eq!(back.mode, Mode::Client);
571        assert_eq!(back.version, 4);
572        assert_eq!(back.transmit_ts, nonce);
573        assert!(back.origin_ts.is_zero() && back.receive_ts.is_zero());
574    }
575}