Skip to main content

ntp_client/
lib.rs

1// Copyright 2026 U.S. Federal Government (in countries where recognized)
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5# Example
6Shows how to use the ntp_client library to fetch the current time according
7to the requested ntp server.
8
9```rust,no_run
10extern crate chrono;
11extern crate ntp_client;
12
13use chrono::TimeZone;
14
15fn main() {
16    let address = "time.nist.gov:123";
17    let result = ntp_client::request(address).unwrap();
18    let unix_time = ntp_client::unix_time::Instant::from(result.transmit_timestamp);
19    let local_time = chrono::Local.timestamp_opt(unix_time.secs(), unix_time.subsec_nanos() as _).unwrap();
20    println!("{}", local_time);
21    println!("Offset: {:.6} seconds", result.offset_seconds);
22}
23```
24*/
25
26#![deny(unsafe_code)]
27#![warn(missing_docs)]
28#![warn(unreachable_pub)]
29
30// Re-export protocol types from ntp_proto for convenience.
31pub use ntp_proto::{error, extension, protocol, unix_time};
32
33/// Shared NTS logic re-exported from `ntp_proto`.
34#[cfg(any(feature = "nts", feature = "nts-smol"))]
35pub(crate) use ntp_proto::nts_common;
36
37/// Clock sample filtering for the continuous NTP client.
38///
39/// Implements a simplified version of the RFC 5905 Section 10 clock filter
40/// algorithm.
41#[cfg(any(feature = "tokio", feature = "smol-runtime"))]
42pub mod filter;
43
44/// Peer selection, clustering, and combining algorithms per RFC 5905 Section 11.2.
45#[cfg(any(feature = "tokio", feature = "smol-runtime"))]
46pub mod selection;
47
48/// Shared types and logic for the continuous NTP client.
49#[cfg(any(feature = "tokio", feature = "smol-runtime"))]
50pub mod client_common;
51
52/// Continuous NTP client with adaptive poll interval management and interleaved mode.
53#[cfg(feature = "tokio")]
54pub mod client;
55
56/// Network Time Security (NTS) client (RFC 8915).
57///
58/// Provides authenticated NTP using TLS 1.3 key establishment and AEAD
59/// per-packet authentication.
60#[cfg(feature = "nts")]
61pub mod nts;
62
63/// System clock adjustment utilities for applying NTP corrections.
64///
65/// Provides platform-specific functions for slewing (gradual) and stepping
66/// (immediate) the system clock. Requires elevated privileges (root/admin).
67#[cfg(feature = "clock")]
68pub mod clock;
69
70/// Clock discipline algorithm (PLL/FLL) per RFC 5905 Section 11.3.
71///
72/// Converts raw offset measurements into phase and frequency corrections
73/// using a hybrid phase-locked / frequency-locked loop state machine.
74#[cfg(feature = "discipline")]
75pub mod discipline;
76
77/// Periodic clock adjustment process per RFC 5905 Section 12.
78///
79/// Drains residual phase error and applies frequency corrections on a
80/// 1-second tick cycle.
81#[cfg(feature = "discipline")]
82pub mod clock_adjust;
83
84/// Symmetric active/passive mode support per RFC 5905 Sections 8-9.
85///
86/// Enables peer-to-peer time synchronization using NTP modes 1 and 2.
87#[cfg(feature = "symmetric")]
88pub mod symmetric;
89
90/// NTP broadcast client support per RFC 5905 Section 8.
91///
92/// Parses and validates mode-5 broadcast packets and computes clock offset
93/// using a calibrated one-way delay. Deprecated by BCP 223 (RFC 8633).
94#[cfg(feature = "broadcast")]
95pub mod broadcast_client;
96
97/// Simple Network Time Protocol (SNTP) client per RFC 4330.
98///
99/// SNTP is a simplified subset of NTP for clients that perform single-shot
100/// time queries without the full NTP discipline algorithms. This module provides
101/// an RFC 4330 compliant SNTP API that wraps the underlying NTP implementation.
102///
103/// See [`sntp`] module documentation for usage examples.
104pub mod sntp;
105
106/// Async NTP client functions using the Tokio runtime.
107///
108/// See [`async_ntp::request`] and [`async_ntp::request_with_timeout`] for details.
109#[cfg(feature = "tokio")]
110pub mod async_ntp;
111
112/// Async NTP client functions using the smol runtime.
113///
114/// See [`smol_ntp::request`] and [`smol_ntp::request_with_timeout`] for details.
115#[cfg(feature = "smol-runtime")]
116pub mod smol_ntp;
117
118/// Continuous NTP client using the smol runtime.
119#[cfg(feature = "smol-runtime")]
120pub mod smol_client;
121
122/// Network Time Security (NTS) client using the smol runtime (RFC 8915).
123///
124/// Provides the same NTS functionality as [`nts`] but using smol
125/// and futures-rustls instead of tokio and tokio-rustls.
126#[cfg(feature = "nts-smol")]
127pub mod smol_nts;
128
129// ============================================================================
130// Core client logic (networking, blocking I/O)
131// ============================================================================
132
133use log::debug;
134use protocol::{ConstPackedSizeBytes, ReadBytes, WriteBytes};
135use std::io;
136use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
137use std::ops::Deref;
138use std::time::Duration;
139
140/// Select the appropriate bind address based on the target address family.
141///
142/// Returns `"0.0.0.0:0"` for IPv4 targets and `"[::]:0"` for IPv6 targets.
143pub(crate) fn bind_addr_for(target: &SocketAddr) -> &'static str {
144    match target {
145        SocketAddr::V4(_) => "0.0.0.0:0",
146        SocketAddr::V6(_) => "[::]:0",
147    }
148}
149
150/// Error returned when the server responds with a Kiss-o'-Death (KoD) packet.
151///
152/// Per RFC 5905 Section 7.4, recipients of kiss codes MUST inspect them and take
153/// the described actions. This error is returned as the inner error of an
154/// [`io::Error`] with kind [`io::ErrorKind::ConnectionRefused`], and can be
155/// extracted via [`io::Error::get_ref`] and `downcast_ref`.
156///
157/// # Caller Responsibilities
158///
159/// - **DENY / RSTR**: The caller MUST stop sending packets to this server.
160/// - **RATE**: The caller MUST reduce its polling interval before retrying.
161///
162/// # Examples
163///
164/// ```no_run
165/// # use std::error::Error;
166/// # fn main() -> Result<(), Box<dyn Error>> {
167/// match ntp_client::request("time.nist.gov:123") {
168///     Ok(result) => println!("Offset: {:.6}s", result.offset_seconds),
169///     Err(e) => {
170///         if let Some(kod) = e.get_ref().and_then(|inner| inner.downcast_ref::<ntp_client::KissOfDeathError>()) {
171///             eprintln!("Kiss-o'-Death: {:?}", kod.code);
172///         }
173///     }
174/// }
175/// # Ok(())
176/// # }
177/// ```
178#[derive(Clone, Copy, Debug)]
179pub struct KissOfDeathError {
180    /// The specific kiss code received from the server.
181    pub code: protocol::KissOfDeath,
182}
183
184impl std::fmt::Display for KissOfDeathError {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        match self.code {
187            protocol::KissOfDeath::Deny => {
188                write!(
189                    f,
190                    "server sent Kiss-o'-Death DENY: access denied, stop querying this server"
191                )
192            }
193            protocol::KissOfDeath::Rstr => {
194                write!(
195                    f,
196                    "server sent Kiss-o'-Death RSTR: access restricted, stop querying this server"
197                )
198            }
199            protocol::KissOfDeath::Rate => {
200                write!(f, "server sent Kiss-o'-Death RATE: reduce polling interval")
201            }
202        }
203    }
204}
205
206impl std::error::Error for KissOfDeathError {}
207
208/// The result of an NTP request, containing the server's response packet
209/// along with computed timing information.
210///
211/// This struct implements `Deref<Target = protocol::Packet>`, so all packet
212/// fields can be accessed directly (e.g., `result.transmit_timestamp`).
213#[derive(Clone, Copy, Debug)]
214pub struct NtpResult {
215    /// The parsed NTP response packet from the server.
216    pub packet: protocol::Packet,
217    /// The destination timestamp (T4): local time when the response was received.
218    ///
219    /// Expressed as an NTP `TimestampFormat` for consistency with the packet timestamps.
220    pub destination_timestamp: protocol::TimestampFormat,
221    /// Clock offset: the estimated difference between the local clock and the server clock.
222    ///
223    /// Computed as `((T2 - T1) + (T3 - T4)) / 2` per RFC 5905 Section 8, where:
224    /// - T1 = origin timestamp (client transmit time)
225    /// - T2 = receive timestamp (server receive time)
226    /// - T3 = transmit timestamp (server transmit time)
227    /// - T4 = destination timestamp (client receive time)
228    ///
229    /// A positive value means the local clock is behind the server.
230    /// A negative value means the local clock is ahead of the server.
231    pub offset_seconds: f64,
232    /// Round-trip delay between the client and server.
233    ///
234    /// Computed as `(T4 - T1) - (T3 - T2)` per RFC 5905 Section 8.
235    pub delay_seconds: f64,
236}
237
238impl Deref for NtpResult {
239    type Target = protocol::Packet;
240    fn deref(&self) -> &Self::Target {
241        &self.packet
242    }
243}
244
245/// Convert a Unix `Instant` to seconds as f64 (relative to Unix epoch).
246fn instant_to_f64(instant: &unix_time::Instant) -> f64 {
247    instant.secs() as f64 + (instant.subsec_nanos() as f64 / 1e9)
248}
249
250/// Compute clock offset and round-trip delay from the four NTP timestamps
251/// using era-aware `Instant` values.
252pub(crate) fn compute_offset_delay(
253    t1: &unix_time::Instant,
254    t2: &unix_time::Instant,
255    t3: &unix_time::Instant,
256    t4: &unix_time::Instant,
257) -> (f64, f64) {
258    let t1 = instant_to_f64(t1);
259    let t2 = instant_to_f64(t2);
260    let t3 = instant_to_f64(t3);
261    let t4 = instant_to_f64(t4);
262    let offset = ((t2 - t1) + (t3 - t4)) / 2.0;
263    let delay = (t4 - t1) - (t3 - t2);
264    (offset, delay)
265}
266
267/// Build an NTP client request packet and serialize it.
268///
269/// Returns the serialized buffer and the origin timestamp (T1).
270pub(crate) fn build_request_packet() -> io::Result<(
271    [u8; protocol::Packet::PACKED_SIZE_BYTES],
272    protocol::TimestampFormat,
273)> {
274    let packet = protocol::Packet {
275        leap_indicator: protocol::LeapIndicator::default(),
276        version: protocol::Version::V4,
277        mode: protocol::Mode::Client,
278        stratum: protocol::Stratum::UNSPECIFIED,
279        poll: 0,
280        precision: 0,
281        root_delay: protocol::ShortFormat::default(),
282        root_dispersion: protocol::ShortFormat::default(),
283        reference_id: protocol::ReferenceIdentifier::PrimarySource(protocol::PrimarySource::Null),
284        reference_timestamp: protocol::TimestampFormat::default(),
285        origin_timestamp: protocol::TimestampFormat::default(),
286        receive_timestamp: protocol::TimestampFormat::default(),
287        transmit_timestamp: unix_time::Instant::now().into(),
288    };
289    let t1 = packet.transmit_timestamp;
290    let mut send_buf = [0u8; protocol::Packet::PACKED_SIZE_BYTES];
291    (&mut send_buf[..]).write_bytes(packet)?;
292    Ok((send_buf, t1))
293}
294
295/// Parse and validate an NTP server response, performing all checks except
296/// origin timestamp verification.
297///
298/// Records T4 (destination timestamp) immediately, then validates source IP,
299/// packet size, mode, Kiss-o'-Death codes, transmit timestamp, and
300/// unsynchronized clock status.
301///
302/// Returns the parsed packet and the destination timestamp (T4). This is used
303/// by both the one-shot [`validate_response`] and the continuous client (which
304/// does its own origin timestamp handling for interleaved mode support).
305pub(crate) fn parse_and_validate_response(
306    recv_buf: &[u8],
307    recv_len: usize,
308    src_addr: SocketAddr,
309    resolved_addrs: &[SocketAddr],
310) -> io::Result<(protocol::Packet, protocol::TimestampFormat)> {
311    // Record T4 (destination timestamp) immediately.
312    let t4_instant = unix_time::Instant::now();
313    let t4: protocol::TimestampFormat = t4_instant.into();
314
315    // Verify the response came from one of the resolved addresses (IP only, port may differ).
316    if !resolved_addrs.iter().any(|a| a.ip() == src_addr.ip()) {
317        return Err(io::Error::new(
318            io::ErrorKind::InvalidData,
319            "response from unexpected source address",
320        ));
321    }
322
323    // Verify minimum packet size.
324    if recv_len < protocol::Packet::PACKED_SIZE_BYTES {
325        return Err(io::Error::new(
326            io::ErrorKind::InvalidData,
327            "NTP response too short",
328        ));
329    }
330
331    // Parse the first 48 bytes as an NTP packet (ignoring extension fields/MAC).
332    let response: protocol::Packet =
333        (&recv_buf[..protocol::Packet::PACKED_SIZE_BYTES]).read_bytes()?;
334
335    // Validate response mode (RFC 5905 Section 8).
336    #[cfg(not(feature = "symmetric"))]
337    let valid_mode = response.mode == protocol::Mode::Server;
338    #[cfg(feature = "symmetric")]
339    let valid_mode = response.mode == protocol::Mode::Server
340        || response.mode == protocol::Mode::SymmetricPassive;
341
342    if !valid_mode {
343        return Err(io::Error::new(
344            io::ErrorKind::InvalidData,
345            "unexpected response mode (expected Server)",
346        ));
347    }
348
349    // Enforce Kiss-o'-Death codes (RFC 5905 Section 7.4).
350    if let protocol::ReferenceIdentifier::KissOfDeath(kod) = response.reference_id {
351        return Err(io::Error::new(
352            io::ErrorKind::ConnectionRefused,
353            KissOfDeathError { code: kod },
354        ));
355    }
356
357    // Validate that the server's transmit timestamp is non-zero.
358    if response.transmit_timestamp.seconds == 0 && response.transmit_timestamp.fraction == 0 {
359        return Err(io::Error::new(
360            io::ErrorKind::InvalidData,
361            "server transmit timestamp is zero",
362        ));
363    }
364
365    // Reject unsynchronized servers (LI=Unknown with non-zero stratum).
366    if response.leap_indicator == protocol::LeapIndicator::Unknown
367        && response.stratum != protocol::Stratum::UNSPECIFIED
368    {
369        return Err(io::Error::new(
370            io::ErrorKind::InvalidData,
371            "server reports unsynchronized clock",
372        ));
373    }
374
375    Ok((response, t4))
376}
377
378/// Validate and parse an NTP server response (one-shot API).
379///
380/// Delegates to [`parse_and_validate_response`] for common checks, then
381/// verifies the origin timestamp (anti-replay) and computes clock offset
382/// and round-trip delay.
383pub(crate) fn validate_response(
384    recv_buf: &[u8],
385    recv_len: usize,
386    src_addr: SocketAddr,
387    resolved_addrs: &[SocketAddr],
388    t1: &protocol::TimestampFormat,
389) -> io::Result<NtpResult> {
390    let (response, t4) = parse_and_validate_response(recv_buf, recv_len, src_addr, resolved_addrs)?;
391
392    // Validate origin timestamp matches what we sent (anti-replay, RFC 5905 Section 8).
393    if response.origin_timestamp != *t1 {
394        return Err(io::Error::new(
395            io::ErrorKind::InvalidData,
396            "origin timestamp mismatch: response does not match our request",
397        ));
398    }
399
400    // Convert all four timestamps to Instant for era-aware offset/delay computation.
401    let t4_instant = unix_time::Instant::from(t4);
402    let t1_instant = unix_time::timestamp_to_instant(*t1, &t4_instant);
403    let t2_instant = unix_time::timestamp_to_instant(response.receive_timestamp, &t4_instant);
404    let t3_instant = unix_time::timestamp_to_instant(response.transmit_timestamp, &t4_instant);
405
406    let (offset_seconds, delay_seconds) =
407        compute_offset_delay(&t1_instant, &t2_instant, &t3_instant, &t4_instant);
408
409    Ok(NtpResult {
410        packet: response,
411        destination_timestamp: t4,
412        offset_seconds,
413        delay_seconds,
414    })
415}
416
417/// Send a blocking request to an NTP server with a hardcoded 5 second timeout.
418///
419/// This is a convenience wrapper around [`request_with_timeout`] with a 5 second timeout.
420///
421/// # Arguments
422///
423/// * `addr` - Any valid socket address (e.g., `"time.nist.gov:123"` or `"192.168.1.1:123"`)
424///
425/// # Returns
426///
427/// Returns an [`NtpResult`] containing the server's response packet and computed timing
428/// information, or an error if the server cannot be reached or the response is invalid.
429///
430/// # Examples
431///
432/// ```no_run
433/// # use std::error::Error;
434/// # fn main() -> Result<(), Box<dyn Error>> {
435/// // Request time from NTP server
436/// let result = ntp_client::request("time.nist.gov:123")?;
437///
438/// // Access packet fields directly via Deref
439/// println!("Server time: {:?}", result.transmit_timestamp);
440/// println!("Stratum: {:?}", result.stratum);
441///
442/// // Access computed timing information
443/// println!("Offset: {:.6} seconds", result.offset_seconds);
444/// println!("Delay: {:.6} seconds", result.delay_seconds);
445/// # Ok(())
446/// # }
447/// ```
448///
449/// # Errors
450///
451/// Returns `io::Error` if:
452/// - Cannot bind to local UDP socket
453/// - Network timeout (5 seconds for read/write)
454/// - Invalid NTP packet response
455/// - DNS resolution fails
456/// - Response fails validation (wrong mode, origin timestamp mismatch, etc.)
457/// - Server sent a Kiss-o'-Death packet (see [`KissOfDeathError`])
458pub fn request<A: ToSocketAddrs>(addr: A) -> io::Result<NtpResult> {
459    request_with_timeout(addr, Duration::from_secs(5))
460}
461
462/// Send a blocking request to an NTP server with a configurable timeout.
463///
464/// Constructs an NTPv4 client-mode packet, sends it to the specified server, and validates
465/// the response per RFC 5905. Returns the parsed response along with computed clock offset
466/// and round-trip delay.
467///
468/// # Arguments
469///
470/// * `addr` - Any valid socket address (e.g., `"time.nist.gov:123"` or `"192.168.1.1:123"`)
471/// * `timeout` - Maximum duration to wait for both sending and receiving the NTP packet
472///
473/// # Returns
474///
475/// Returns an [`NtpResult`] containing the server's response packet and computed timing
476/// information, or an error if the server cannot be reached or the response is invalid.
477///
478/// # Examples
479///
480/// ```no_run
481/// # use std::error::Error;
482/// # use std::time::Duration;
483/// # fn main() -> Result<(), Box<dyn Error>> {
484/// // Request time with a 10 second timeout
485/// let result = ntp_client::request_with_timeout("time.nist.gov:123", Duration::from_secs(10))?;
486/// println!("Offset: {:.6} seconds", result.offset_seconds);
487/// println!("Delay: {:.6} seconds", result.delay_seconds);
488/// # Ok(())
489/// # }
490/// ```
491///
492/// # Errors
493///
494/// Returns `io::Error` if:
495/// - Cannot bind to local UDP socket
496/// - Network timeout (specified duration exceeded)
497/// - Invalid NTP packet response
498/// - DNS resolution fails
499/// - Response source address does not match the target server
500/// - Response origin timestamp does not match our request (anti-replay)
501/// - Server responds with unexpected mode or zero transmit timestamp
502/// - Server reports unsynchronized clock (LI=Unknown with non-zero stratum)
503/// - Server sent a Kiss-o'-Death packet (see [`KissOfDeathError`])
504pub fn request_with_timeout<A: ToSocketAddrs>(addr: A, timeout: Duration) -> io::Result<NtpResult> {
505    // Resolve the target address eagerly so we can verify the response source.
506    let resolved_addrs: Vec<SocketAddr> = addr.to_socket_addrs()?.collect();
507    if resolved_addrs.is_empty() {
508        return Err(io::Error::new(
509            io::ErrorKind::InvalidInput,
510            "address resolved to no socket addresses",
511        ));
512    }
513    let target_addr = resolved_addrs[0];
514
515    // Build the request packet (shared with async path).
516    let (send_buf, t1) = build_request_packet()?;
517
518    // Create the socket from which we will send the packet.
519    let sock = UdpSocket::bind(bind_addr_for(&target_addr))?;
520    sock.set_read_timeout(Some(timeout))?;
521    sock.set_write_timeout(Some(timeout))?;
522
523    // Send the data.
524    let sz = sock.send_to(&send_buf, target_addr)?;
525    debug!("{:?}", sock.local_addr());
526    debug!("sent: {}", sz);
527
528    // Receive the response into a larger buffer to accommodate extension fields.
529    let mut recv_buf = [0u8; 1024];
530    let (recv_len, src_addr) = sock.recv_from(&mut recv_buf[..])?;
531    debug!("recv: {} bytes from {:?}", recv_len, src_addr);
532
533    // Validate and parse the response (shared with async path).
534    validate_response(&recv_buf, recv_len, src_addr, &resolved_addrs, &t1)
535}
536
537#[cfg(test)]
538#[test]
539fn test_request_nist() {
540    match request_with_timeout("time.nist.gov:123", Duration::from_secs(10)) {
541        Ok(_) => {}
542        Err(e) if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut => {
543            eprintln!("skipping test_request_nist: NTP port unreachable ({e})");
544        }
545        Err(e) => panic!("unexpected error from time.nist.gov: {e}"),
546    }
547}
548
549#[cfg(test)]
550#[test]
551fn test_request_nist_alt() {
552    match request_with_timeout("time-a-g.nist.gov:123", Duration::from_secs(10)) {
553        Ok(_) => {}
554        Err(e) if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut => {
555            eprintln!("skipping test_request_nist_alt: NTP port unreachable ({e})");
556        }
557        Err(e) => panic!("unexpected error from time-a-g.nist.gov: {e}"),
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    // ── compute_offset_delay ──────────────────────────────────────
566
567    #[test]
568    fn test_offset_delay_symmetric() {
569        // T1=0, T2=0.5, T3=0.5, T4=1.0
570        // offset = ((0.5-0)+(0.5-1))/2 = (0.5+(-0.5))/2 = 0
571        // delay = (1-0)-(0.5-0.5) = 1.0
572        let t1 = unix_time::Instant::new(0, 0);
573        let t2 = unix_time::Instant::new(0, 500_000_000);
574        let t3 = unix_time::Instant::new(0, 500_000_000);
575        let t4 = unix_time::Instant::new(1, 0);
576        let (offset, delay) = compute_offset_delay(&t1, &t2, &t3, &t4);
577        assert!(offset.abs() < 1e-9, "expected ~0 offset, got {offset}");
578        assert!(
579            (delay - 1.0).abs() < 1e-9,
580            "expected 1.0 delay, got {delay}"
581        );
582    }
583
584    #[test]
585    fn test_offset_delay_local_behind() {
586        // Client behind by 1s: T1=0, T2=1.5, T3=1.5, T4=1.0
587        // offset = ((1.5-0)+(1.5-1))/2 = (1.5+0.5)/2 = 1.0
588        // delay = (1-0)-(1.5-1.5) = 1.0
589        let t1 = unix_time::Instant::new(0, 0);
590        let t2 = unix_time::Instant::new(1, 500_000_000);
591        let t3 = unix_time::Instant::new(1, 500_000_000);
592        let t4 = unix_time::Instant::new(1, 0);
593        let (offset, delay) = compute_offset_delay(&t1, &t2, &t3, &t4);
594        assert!(
595            (offset - 1.0).abs() < 1e-9,
596            "expected 1.0 offset, got {offset}"
597        );
598        assert!(
599            (delay - 1.0).abs() < 1e-9,
600            "expected 1.0 delay, got {delay}"
601        );
602    }
603
604    #[test]
605    fn test_offset_delay_local_ahead() {
606        // Client ahead by 1s: T1=10, T2=9.25, T3=9.75, T4=11
607        // offset = ((9.25-10)+(9.75-11))/2 = (-0.75+(-1.25))/2 = -1.0
608        // delay = (11-10)-(9.75-9.25) = 1.0 - 0.5 = 0.5
609        let t1 = unix_time::Instant::new(10, 0);
610        let t2 = unix_time::Instant::new(9, 250_000_000);
611        let t3 = unix_time::Instant::new(9, 750_000_000);
612        let t4 = unix_time::Instant::new(11, 0);
613        let (offset, delay) = compute_offset_delay(&t1, &t2, &t3, &t4);
614        assert!(
615            (offset - (-1.0)).abs() < 1e-9,
616            "expected -1.0 offset, got {offset}"
617        );
618        assert!(
619            (delay - 0.5).abs() < 1e-9,
620            "expected 0.5 delay, got {delay}"
621        );
622    }
623
624    #[test]
625    fn test_offset_delay_zero_processing_time() {
626        // Server processes instantly, RTT=0.1s: T1=0, T2=0.05, T3=0.05, T4=0.1
627        // offset = ((0.05-0)+(0.05-0.1))/2 = (0.05+(-0.05))/2 = 0
628        // delay = (0.1-0)-(0.05-0.05) = 0.1
629        let t1 = unix_time::Instant::new(0, 0);
630        let t2 = unix_time::Instant::new(0, 50_000_000);
631        let t3 = unix_time::Instant::new(0, 50_000_000);
632        let t4 = unix_time::Instant::new(0, 100_000_000);
633        let (offset, delay) = compute_offset_delay(&t1, &t2, &t3, &t4);
634        assert!(offset.abs() < 1e-9, "expected ~0 offset, got {offset}");
635        assert!(
636            (delay - 0.1).abs() < 1e-9,
637            "expected 0.1 delay, got {delay}"
638        );
639    }
640
641    // ── build_request_packet ──────────────────────────────────────
642
643    #[test]
644    fn test_build_request_packet_structure() {
645        let (buf, t1) = build_request_packet().unwrap();
646
647        // Deserialize and verify fields.
648        let pkt: protocol::Packet = (&buf[..protocol::Packet::PACKED_SIZE_BYTES])
649            .read_bytes()
650            .unwrap();
651        assert_eq!(pkt.version, protocol::Version::V4);
652        assert_eq!(pkt.mode, protocol::Mode::Client);
653        assert_eq!(pkt.stratum, protocol::Stratum::UNSPECIFIED);
654        assert_eq!(pkt.transmit_timestamp, t1);
655        // T1 should be non-zero (set to current time).
656        assert!(t1.seconds != 0 || t1.fraction != 0);
657    }
658
659    #[test]
660    fn test_build_request_packet_size() {
661        let (buf, _) = build_request_packet().unwrap();
662        assert_eq!(buf.len(), protocol::Packet::PACKED_SIZE_BYTES);
663        assert_eq!(buf.len(), 48);
664    }
665
666    // ── parse_and_validate_response ───────────────────────────────
667
668    /// Helper: build a valid 48-byte server response buffer.
669    fn make_server_response(
670        mode: protocol::Mode,
671        li: protocol::LeapIndicator,
672        stratum: protocol::Stratum,
673        ref_id: protocol::ReferenceIdentifier,
674        transmit_secs: u32,
675    ) -> [u8; 48] {
676        let pkt = protocol::Packet {
677            leap_indicator: li,
678            version: protocol::Version::V4,
679            mode,
680            stratum,
681            poll: 6,
682            precision: -20,
683            root_delay: protocol::ShortFormat::default(),
684            root_dispersion: protocol::ShortFormat::default(),
685            reference_id: ref_id,
686            reference_timestamp: protocol::TimestampFormat::default(),
687            origin_timestamp: protocol::TimestampFormat {
688                seconds: 100,
689                fraction: 0,
690            },
691            receive_timestamp: protocol::TimestampFormat {
692                seconds: 3_913_056_000,
693                fraction: 0,
694            },
695            transmit_timestamp: protocol::TimestampFormat {
696                seconds: transmit_secs,
697                fraction: 1,
698            },
699        };
700        let mut buf = [0u8; 48];
701        (&mut buf[..]).write_bytes(pkt).unwrap();
702        buf
703    }
704
705    fn valid_server_buf() -> [u8; 48] {
706        make_server_response(
707            protocol::Mode::Server,
708            protocol::LeapIndicator::NoWarning,
709            protocol::Stratum(2),
710            protocol::ReferenceIdentifier::SecondaryOrClient([127, 0, 0, 1]),
711            3_913_056_001,
712        )
713    }
714
715    fn src_addr() -> SocketAddr {
716        "127.0.0.1:123".parse().unwrap()
717    }
718
719    #[test]
720    fn test_validate_accepts_valid_response() {
721        let buf = valid_server_buf();
722        let addrs = vec![src_addr()];
723        let result = parse_and_validate_response(&buf, 48, src_addr(), &addrs);
724        assert!(result.is_ok());
725        let (pkt, _t4) = result.unwrap();
726        assert_eq!(pkt.mode, protocol::Mode::Server);
727    }
728
729    #[test]
730    fn test_validate_rejects_wrong_source_ip() {
731        let buf = valid_server_buf();
732        let addrs = vec!["10.0.0.1:123".parse().unwrap()];
733        let result = parse_and_validate_response(&buf, 48, src_addr(), &addrs);
734        assert!(result.is_err());
735        assert!(
736            result
737                .unwrap_err()
738                .to_string()
739                .contains("unexpected source")
740        );
741    }
742
743    #[test]
744    fn test_validate_rejects_short_packet() {
745        let buf = valid_server_buf();
746        let addrs = vec![src_addr()];
747        let result = parse_and_validate_response(&buf, 47, src_addr(), &addrs);
748        assert!(result.is_err());
749        assert!(result.unwrap_err().to_string().contains("too short"));
750    }
751
752    #[test]
753    fn test_validate_rejects_client_mode() {
754        let buf = make_server_response(
755            protocol::Mode::Client,
756            protocol::LeapIndicator::NoWarning,
757            protocol::Stratum(2),
758            protocol::ReferenceIdentifier::SecondaryOrClient([127, 0, 0, 1]),
759            3_913_056_001,
760        );
761        let addrs = vec![src_addr()];
762        let result = parse_and_validate_response(&buf, 48, src_addr(), &addrs);
763        assert!(result.is_err());
764        assert!(
765            result
766                .unwrap_err()
767                .to_string()
768                .contains("unexpected response mode")
769        );
770    }
771
772    #[test]
773    fn test_validate_rejects_kiss_of_death() {
774        let buf = make_server_response(
775            protocol::Mode::Server,
776            protocol::LeapIndicator::NoWarning,
777            protocol::Stratum::UNSPECIFIED,
778            protocol::ReferenceIdentifier::KissOfDeath(protocol::KissOfDeath::Deny),
779            3_913_056_001,
780        );
781        let addrs = vec![src_addr()];
782        let result = parse_and_validate_response(&buf, 48, src_addr(), &addrs);
783        let err = result.unwrap_err();
784        assert_eq!(err.kind(), io::ErrorKind::ConnectionRefused);
785        let kod = err
786            .get_ref()
787            .unwrap()
788            .downcast_ref::<KissOfDeathError>()
789            .unwrap();
790        assert!(matches!(kod.code, protocol::KissOfDeath::Deny));
791    }
792
793    #[test]
794    fn test_validate_rejects_zero_transmit() {
795        // Build a packet with fully zero transmit timestamp.
796        let pkt = protocol::Packet {
797            leap_indicator: protocol::LeapIndicator::NoWarning,
798            version: protocol::Version::V4,
799            mode: protocol::Mode::Server,
800            stratum: protocol::Stratum(2),
801            poll: 6,
802            precision: -20,
803            root_delay: protocol::ShortFormat::default(),
804            root_dispersion: protocol::ShortFormat::default(),
805            reference_id: protocol::ReferenceIdentifier::SecondaryOrClient([127, 0, 0, 1]),
806            reference_timestamp: protocol::TimestampFormat::default(),
807            origin_timestamp: protocol::TimestampFormat::default(),
808            receive_timestamp: protocol::TimestampFormat::default(),
809            transmit_timestamp: protocol::TimestampFormat {
810                seconds: 0,
811                fraction: 0,
812            },
813        };
814        let mut raw = [0u8; 48];
815        (&mut raw[..]).write_bytes(pkt).unwrap();
816        let addrs = vec![src_addr()];
817        let result = parse_and_validate_response(&raw, 48, src_addr(), &addrs);
818        assert!(result.is_err());
819        assert!(
820            result
821                .unwrap_err()
822                .to_string()
823                .contains("transmit timestamp is zero")
824        );
825    }
826
827    #[test]
828    fn test_validate_rejects_unsynchronized() {
829        let buf = make_server_response(
830            protocol::Mode::Server,
831            protocol::LeapIndicator::Unknown,
832            protocol::Stratum(2), // non-zero stratum + LI=Unknown = unsynchronized
833            protocol::ReferenceIdentifier::SecondaryOrClient([127, 0, 0, 1]),
834            3_913_056_001,
835        );
836        let addrs = vec![src_addr()];
837        let result = parse_and_validate_response(&buf, 48, src_addr(), &addrs);
838        assert!(result.is_err());
839        assert!(result.unwrap_err().to_string().contains("unsynchronized"));
840    }
841
842    #[test]
843    fn test_validate_allows_li_unknown_stratum_zero() {
844        // LI=Unknown with stratum 0 (UNSPECIFIED) is OK — it's a KoD or reference clock.
845        // But stratum 0 with a non-KoD ref_id should pass the LI check.
846        let buf = make_server_response(
847            protocol::Mode::Server,
848            protocol::LeapIndicator::Unknown,
849            protocol::Stratum::UNSPECIFIED,
850            protocol::ReferenceIdentifier::PrimarySource(protocol::PrimarySource::Gps),
851            3_913_056_001,
852        );
853        let addrs = vec![src_addr()];
854        let result = parse_and_validate_response(&buf, 48, src_addr(), &addrs);
855        assert!(result.is_ok());
856    }
857
858    #[test]
859    fn test_validate_accepts_different_port() {
860        // Source port doesn't need to match — only IP.
861        let buf = valid_server_buf();
862        let addrs = vec!["127.0.0.1:456".parse().unwrap()];
863        let result = parse_and_validate_response(&buf, 48, src_addr(), &addrs);
864        assert!(result.is_ok());
865    }
866
867    // ── KissOfDeathError display ──────────────────────────────────
868
869    #[test]
870    fn test_kod_display_deny() {
871        let kod = KissOfDeathError {
872            code: protocol::KissOfDeath::Deny,
873        };
874        assert!(kod.to_string().contains("DENY"));
875    }
876
877    #[test]
878    fn test_kod_display_rstr() {
879        let kod = KissOfDeathError {
880            code: protocol::KissOfDeath::Rstr,
881        };
882        assert!(kod.to_string().contains("RSTR"));
883    }
884
885    #[test]
886    fn test_kod_display_rate() {
887        let kod = KissOfDeathError {
888            code: protocol::KissOfDeath::Rate,
889        };
890        assert!(kod.to_string().contains("RATE"));
891    }
892}