use core::fmt;
pub const HEADER_LEN: usize = 48;
pub const UNIX_EPOCH_OFFSET: u64 = 2_208_988_800;
const FRAC: f64 = 4_294_967_296.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct NtpTimestamp(pub u64);
impl NtpTimestamp {
pub const ZERO: NtpTimestamp = NtpTimestamp(0);
pub fn from_parts(seconds: u32, fraction: u32) -> Self {
NtpTimestamp(((seconds as u64) << 32) | fraction as u64)
}
pub fn seconds(self) -> u32 {
(self.0 >> 32) as u32
}
pub fn fraction(self) -> u32 {
self.0 as u32
}
pub fn from_unix(secs: i64, nanos: u32) -> Self {
let ntp_secs = (secs.wrapping_add(UNIX_EPOCH_OFFSET as i64)) as u64;
let frac = ((nanos as u64) << 32) / 1_000_000_000;
NtpTimestamp(((ntp_secs & 0xFFFF_FFFF) << 32) | (frac & 0xFFFF_FFFF))
}
pub fn seconds_since(self, earlier: NtpTimestamp) -> f64 {
let diff = self.0.wrapping_sub(earlier.0) as i64;
diff as f64 / FRAC
}
pub fn is_zero(self) -> bool {
self.0 == 0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct NtpShort(pub u32);
impl NtpShort {
pub fn to_seconds(self) -> f64 {
self.0 as f64 / 65_536.0
}
pub fn from_seconds(s: f64) -> Self {
let clamped = s.clamp(0.0, 65_535.999);
NtpShort((clamped * 65_536.0) as u32)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum LeapIndicator {
NoWarning = 0,
LastMinute61 = 1,
LastMinute59 = 2,
Unsynchronized = 3,
}
impl LeapIndicator {
#[inline(always)]
fn from_bits(b: u8) -> Self {
match b & 0b11 {
0 => LeapIndicator::NoWarning,
1 => LeapIndicator::LastMinute61,
2 => LeapIndicator::LastMinute59,
_ => LeapIndicator::Unsynchronized,
}
}
#[inline(always)]
fn bits(self) -> u8 {
self as u8
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum Mode {
Reserved = 0,
SymmetricActive = 1,
SymmetricPassive = 2,
Client = 3,
Server = 4,
Broadcast = 5,
Control = 6,
Private = 7,
}
impl Mode {
#[inline(always)]
fn from_bits(b: u8) -> Self {
match b & 0b111 {
1 => Mode::SymmetricActive,
2 => Mode::SymmetricPassive,
3 => Mode::Client,
4 => Mode::Server,
5 => Mode::Broadcast,
6 => Mode::Control,
7 => Mode::Private,
_ => Mode::Reserved,
}
}
#[inline(always)]
fn bits(self) -> u8 {
self as u8
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParseError {
TooShort { len: usize },
BadVersion { version: u8 },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::TooShort { len } => {
write!(f, "packet is {len} bytes; NTP header needs {HEADER_LEN}")
}
ParseError::BadVersion { version } => {
write!(f, "unsupported NTP version {version} (expected 3 or 4)")
}
}
}
}
impl core::error::Error for ParseError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NtpPacket {
pub leap: LeapIndicator,
pub version: u8,
pub mode: Mode,
pub stratum: u8,
pub poll: i8,
pub precision: i8,
pub root_delay: NtpShort,
pub root_dispersion: NtpShort,
pub reference_id: [u8; 4],
pub reference_ts: NtpTimestamp,
pub origin_ts: NtpTimestamp,
pub receive_ts: NtpTimestamp,
pub transmit_ts: NtpTimestamp,
}
#[inline(always)]
fn be_u32<const AT: usize>(h: &[u8; HEADER_LEN]) -> u32 {
u32::from_be_bytes([h[AT], h[AT + 1], h[AT + 2], h[AT + 3]])
}
#[inline(always)]
fn be_u64<const AT: usize>(h: &[u8; HEADER_LEN]) -> u64 {
u64::from_be_bytes([
h[AT],
h[AT + 1],
h[AT + 2],
h[AT + 3],
h[AT + 4],
h[AT + 5],
h[AT + 6],
h[AT + 7],
])
}
impl NtpPacket {
pub fn client_request(version: u8, transmit_ts: NtpTimestamp) -> Self {
NtpPacket {
leap: LeapIndicator::NoWarning,
version,
mode: Mode::Client,
stratum: 0,
poll: 0,
precision: 0x20u8 as i8,
root_delay: NtpShort(0),
root_dispersion: NtpShort(0),
reference_id: [0; 4],
reference_ts: NtpTimestamp::ZERO,
origin_ts: NtpTimestamp::ZERO,
receive_ts: NtpTimestamp::ZERO,
transmit_ts,
}
}
pub fn parse(buf: &[u8]) -> Result<NtpPacket, ParseError> {
let Some(h) = buf.first_chunk::<HEADER_LEN>() else {
return Err(ParseError::TooShort { len: buf.len() });
};
let b0 = h[0];
let version = (b0 >> 3) & 0b111;
if !(3..=4).contains(&version) {
return Err(ParseError::BadVersion { version });
}
Ok(NtpPacket {
leap: LeapIndicator::from_bits(b0 >> 6),
version,
mode: Mode::from_bits(b0),
stratum: h[1],
poll: h[2] as i8,
precision: h[3] as i8,
root_delay: NtpShort(be_u32::<4>(h)),
root_dispersion: NtpShort(be_u32::<8>(h)),
reference_id: [h[12], h[13], h[14], h[15]],
reference_ts: NtpTimestamp(be_u64::<16>(h)),
origin_ts: NtpTimestamp(be_u64::<24>(h)),
receive_ts: NtpTimestamp(be_u64::<32>(h)),
transmit_ts: NtpTimestamp(be_u64::<40>(h)),
})
}
pub fn write(&self, buf: &mut [u8; HEADER_LEN]) {
buf[0] = (self.leap.bits() << 6) | ((self.version & 0b111) << 3) | self.mode.bits();
buf[1] = self.stratum;
buf[2] = self.poll as u8;
buf[3] = self.precision as u8;
buf[4..8].copy_from_slice(&self.root_delay.0.to_be_bytes());
buf[8..12].copy_from_slice(&self.root_dispersion.0.to_be_bytes());
buf[12..16].copy_from_slice(&self.reference_id);
buf[16..24].copy_from_slice(&self.reference_ts.0.to_be_bytes());
buf[24..32].copy_from_slice(&self.origin_ts.0.to_be_bytes());
buf[32..40].copy_from_slice(&self.receive_ts.0.to_be_bytes());
buf[40..48].copy_from_slice(&self.transmit_ts.0.to_be_bytes());
}
pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
let mut buf = [0u8; HEADER_LEN];
self.write(&mut buf);
buf
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExtensionField<'a> {
pub field_type: u16,
pub value: &'a [u8],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Trailer<'a> {
Extension(ExtensionField<'a>),
Opaque(&'a [u8]),
}
pub fn extension_fields(packet: &[u8]) -> ExtensionIter<'_> {
let rest = packet.get(HEADER_LEN..).unwrap_or(&[]);
ExtensionIter { rest }
}
pub struct ExtensionIter<'a> {
rest: &'a [u8],
}
impl<'a> Iterator for ExtensionIter<'a> {
type Item = Trailer<'a>;
fn next(&mut self) -> Option<Trailer<'a>> {
if self.rest.is_empty() {
return None;
}
if self.rest.len() >= 4 {
let field_type = u16::from_be_bytes([self.rest[0], self.rest[1]]);
let len = u16::from_be_bytes([self.rest[2], self.rest[3]]) as usize;
if len >= 16 && len.is_multiple_of(4) && len <= self.rest.len() {
let value = &self.rest[4..len];
self.rest = &self.rest[len..];
return Some(Trailer::Extension(ExtensionField { field_type, value }));
}
}
let opaque = self.rest;
self.rest = &[];
Some(Trailer::Opaque(opaque))
}
}
pub fn offset_delay(t1: f64, t2: f64, t3: f64, t4: f64) -> (f64, f64) {
let offset = ((t2 - t1) + (t3 - t4)) / 2.0;
let delay = (t4 - t1) - (t3 - t2);
(offset, delay)
}
#[cfg(test)]
mod precision_tests {
use super::*;
const TICK: f64 = 1.0 / 4_294_967_296.0;
#[test]
fn differences_keep_sub_nanosecond_resolution() {
let secs = 1_787_856_000i64;
let a = NtpTimestamp::from_unix(secs, 0);
let b = NtpTimestamp::from_unix(secs, 1);
let exact = b.seconds_since(a);
assert!(
exact > 0.0,
"a 1 ns step vanished entirely in the fixed-point difference"
);
assert!(
(exact - 1e-9).abs() <= TICK,
"fixed-point difference gave {exact} s for a 1 ns step"
);
let ulp = (secs as f64).next_up() - secs as f64;
assert!(
ulp > 200e-9,
"this test assumes an f64 at the Unix epoch is coarse; ULP is {ulp} s"
);
}
#[test]
fn the_2038_exponent_step_does_not_reach_the_difference() {
let secs = 2_147_500_000i64;
let a = NtpTimestamp::from_unix(secs, 0);
let b = NtpTimestamp::from_unix(secs, 100);
let exact = b.seconds_since(a);
assert!(
(exact - 100e-9).abs() <= TICK,
"a 100 ns step past 2038 measured as {exact} s"
);
let ulp = (secs as f64).next_up() - secs as f64;
assert!(
ulp > 400e-9,
"expected the post-2038 f64 gap to exceed 400 ns, got {ulp} s"
);
}
#[test]
fn a_difference_spans_the_era_boundary() {
let before = NtpTimestamp::from_unix(2_085_978_495, 0);
let after = NtpTimestamp::from_unix(2_085_978_497, 0);
let delta = after.seconds_since(before);
assert!(
(delta - 2.0).abs() <= TICK,
"two seconds across the era wrap measured as {delta}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_header() {
let p = NtpPacket {
leap: LeapIndicator::NoWarning,
version: 4,
mode: Mode::Server,
stratum: 2,
poll: 6,
precision: -20,
root_delay: NtpShort::from_seconds(0.015),
root_dispersion: NtpShort::from_seconds(0.002),
reference_id: *b"GPS\0",
reference_ts: NtpTimestamp::from_unix(1_756_200_000, 0),
origin_ts: NtpTimestamp(0x0102_0304_0506_0708),
receive_ts: NtpTimestamp::from_unix(1_756_200_100, 500_000_000),
transmit_ts: NtpTimestamp::from_unix(1_756_200_100, 500_100_000),
};
let bytes = p.to_bytes();
let q = NtpPacket::parse(&bytes).expect("parse back");
assert_eq!(p, q);
}
#[test]
fn field_offsets_match_rfc5905() {
let mut p = NtpPacket::client_request(4, NtpTimestamp(0xAABB_CCDD_EEFF_0011));
p.origin_ts = NtpTimestamp(0x1111_1111_1111_1111);
p.receive_ts = NtpTimestamp(0x2222_2222_2222_2222);
p.reference_ts = NtpTimestamp(0x3333_3333_3333_3333);
let b = p.to_bytes();
assert_eq!(b[0], 0b00_100_011); assert_eq!(&b[16..24], &[0x33; 8]); assert_eq!(&b[24..32], &[0x11; 8]); assert_eq!(&b[32..40], &[0x22; 8]); assert_eq!(&b[40..48], &0xAABB_CCDD_EEFF_0011u64.to_be_bytes()); }
#[test]
fn short_and_bad_version_are_errors() {
assert_eq!(
NtpPacket::parse(&[0u8; 20]),
Err(ParseError::TooShort { len: 20 })
);
let mut b = [0u8; 48];
b[0] = 2 << 3; assert_eq!(
NtpPacket::parse(&b),
Err(ParseError::BadVersion { version: 2 })
);
}
#[test]
fn timestamp_wraparound_diff() {
let before = NtpTimestamp(u64::MAX - (1u64 << 31)); let after = NtpTimestamp(1u64 << 31); let d = after.seconds_since(before);
assert!((d - 1.0).abs() < 1e-9, "got {d}");
}
#[test]
fn exchange_math() {
let t1 = 10.000; let t2 = 10.125; let t3 = 10.126;
let t4 = 10.051; let (offset, delay) = offset_delay(t1, t2, t3, t4);
assert!((offset - 0.100).abs() < 1e-9, "offset {offset}");
assert!((delay - 0.050).abs() < 1e-9, "delay {delay}");
}
#[test]
fn extension_iteration_handles_garbage() {
let mut buf = [0u8; HEADER_LEN + 16 + 3];
buf[0] = (4 << 3) | 3;
buf[48..50].copy_from_slice(&0x0104u16.to_be_bytes()); buf[50..52].copy_from_slice(&16u16.to_be_bytes()); buf[52..64].copy_from_slice(&[0xAB; 12]); buf[64..67].copy_from_slice(&[1, 2, 3]);
let mut it = extension_fields(&buf);
match it.next() {
Some(Trailer::Extension(ef)) => {
assert_eq!(ef.field_type, 0x0104);
assert_eq!(ef.value, &[0xAB; 12][..]);
}
other => panic!("expected an extension field, got {other:?}"),
}
assert!(matches!(it.next(), Some(Trailer::Opaque(&[1, 2, 3]))));
assert!(it.next().is_none(), "iteration ends after the opaque tail");
}
#[test]
fn client_request_round_trips_in_48_bytes() {
let nonce = NtpTimestamp(0x1234_5678_9ABC_DEF0);
let req = NtpPacket::client_request(4, nonce);
let wire = req.to_bytes();
assert_eq!(wire.len(), HEADER_LEN, "an SNTP request is 48 bytes");
let back = NtpPacket::parse(&wire).expect("our own request must parse");
assert_eq!(req, back, "round trip changed the packet");
assert_eq!(back.mode, Mode::Client);
assert_eq!(back.version, 4);
assert_eq!(back.transmit_ts, nonce);
assert!(back.origin_ts.is_zero() && back.receive_ts.is_zero());
}
}