mirror of
https://github.com/soypat/lneto.git
synced 2026-08-06 16:03:42 +00:00
bba913751a
Implement full two-exchange NTP client state machine (RFC 5905 §8). First exchange stores offset/RTT, second averages both for improved accuracy. Client places T1 in TransmitTime per RFC 5905 §8; server echoes it as OriginTime in the response. Add NTP extension field codec (RFC 7822) with NextExtField iterator and AppendExtField builder. Add NTS extension type constants from RFC 8915. Add NTP Server (StackNode) that receives client requests via Demux and builds server responses via Encapsulate with configurable stratum, precision, reference ID, and pending request queue. Add Frame accessor methods: RawData(), ExtensionFields(), ValidateSize(), and Timestamp.Uint64(). Add ntp-client and ntp-server example programs with CalculateSystemPrecision, retry limits, and backoff. Use internal.LogAttrs pattern for non-allocating structured logging in the client, matching the tcp/debug.go convention. Generated with Claude assistance. Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
177 lines
4.6 KiB
Go
177 lines
4.6 KiB
Go
package ntp
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/soypat/lneto"
|
|
)
|
|
|
|
func TestNextExtField_Empty(t *testing.T) {
|
|
field, n, err := NextExtField(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(field.RawData()) != 0 {
|
|
t.Errorf("NextExtField(nil) RawData len = %d; want 0", len(field.RawData()))
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("NextExtField(nil) n = %d; want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestAppendAndIterateExtFields(t *testing.T) {
|
|
uid := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
|
cookie := []byte{0xAA, 0xBB, 0xCC}
|
|
|
|
var buf []byte
|
|
buf = AppendExtField(buf, ExtNTSUniqueID, uid)
|
|
buf = AppendExtField(buf, ExtNTSCookie, cookie)
|
|
|
|
// Each field should be padded to 4-byte boundary.
|
|
// UID: 4 header + 16 value = 20 bytes (already aligned)
|
|
// Cookie: 4 header + 3 value + 1 padding = 8 bytes
|
|
const wantLen = 20 + 8
|
|
if len(buf) != wantLen {
|
|
t.Fatalf("AppendExtField total len = %d; want %d", len(buf), wantLen)
|
|
}
|
|
|
|
off := 0
|
|
field, n, err := NextExtField(buf[off:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
off += n
|
|
if field.Type() != ExtNTSUniqueID {
|
|
t.Errorf("field 1 Type() = %#x; want ExtNTSUniqueID (%#x)", field.Type(), ExtNTSUniqueID)
|
|
}
|
|
if string(field.Value()) != string(uid) {
|
|
t.Errorf("field 1 Value() mismatch")
|
|
}
|
|
|
|
field, n, err = NextExtField(buf[off:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
off += n
|
|
if field.Type() != ExtNTSCookie {
|
|
t.Errorf("field 2 Type() = %#x; want ExtNTSCookie (%#x)", field.Type(), ExtNTSCookie)
|
|
}
|
|
// Value() includes the 4-byte-aligned body (RFC 7822 §2.1 length includes padding).
|
|
wantCookiePadded := []byte{0xAA, 0xBB, 0xCC, 0x00}
|
|
if string(field.Value()) != string(wantCookiePadded) {
|
|
t.Errorf("field 2 Value() = %v; want %v", field.Value(), wantCookiePadded)
|
|
}
|
|
|
|
field, n, err = NextExtField(buf[off:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(field.RawData()) != 0 {
|
|
t.Errorf("NextExtField after last: RawData len = %d; want 0", len(field.RawData()))
|
|
}
|
|
_ = n
|
|
}
|
|
|
|
func TestNextExtField_Errors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
buf []byte
|
|
}{
|
|
{name: "truncated/2bytes", buf: []byte{0x01, 0x04}},
|
|
{name: "length_below_min", buf: []byte{0x01, 0x04, 0x00, 0x02}},
|
|
{name: "length_unaligned", buf: []byte{0x01, 0x04, 0x00, 0x05, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}},
|
|
{name: "length_exceeds_buf", buf: []byte{0x01, 0x04, 0x00, 0x08, 0x00}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, _, err := NextExtField(tc.buf)
|
|
if err == nil {
|
|
t.Errorf("NextExtField(%x) = nil error; want error", tc.buf)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFrameExtensionFields(t *testing.T) {
|
|
t.Run("with_extensions", func(t *testing.T) {
|
|
buf := make([]byte, SizeHeader+8)
|
|
frm, err := NewFrame(buf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := frm.ExtensionFields()
|
|
if len(p) != 8 {
|
|
t.Errorf("ExtensionFields() len = %d; want 8", len(p))
|
|
}
|
|
})
|
|
t.Run("header_only", func(t *testing.T) {
|
|
buf := make([]byte, SizeHeader)
|
|
frm, _ := NewFrame(buf)
|
|
if len(frm.ExtensionFields()) != 0 {
|
|
t.Errorf("ExtensionFields() len = %d; want 0 for header-only frame", len(frm.ExtensionFields()))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFrameValidateSize(t *testing.T) {
|
|
t.Run("header_only", func(t *testing.T) {
|
|
var v lneto.Validator
|
|
buf := make([]byte, SizeHeader)
|
|
frm, _ := NewFrame(buf)
|
|
frm.ValidateSize(&v)
|
|
if v.HasError() {
|
|
t.Errorf("ValidateSize(header-only) = %v; want no error", v.ErrPop())
|
|
}
|
|
})
|
|
t.Run("valid_extension", func(t *testing.T) {
|
|
var v lneto.Validator
|
|
ext := AppendExtField(nil, ExtNTSUniqueID, make([]byte, 16))
|
|
buf := make([]byte, SizeHeader+len(ext))
|
|
copy(buf[SizeHeader:], ext)
|
|
frm, _ := NewFrame(buf)
|
|
frm.ValidateSize(&v)
|
|
if v.HasError() {
|
|
t.Errorf("ValidateSize(valid ext) = %v; want no error", v.ErrPop())
|
|
}
|
|
})
|
|
t.Run("malformed_extension", func(t *testing.T) {
|
|
var v lneto.Validator
|
|
buf := make([]byte, SizeHeader+4)
|
|
buf[SizeHeader+2] = 0x00
|
|
buf[SizeHeader+3] = 0x05 // length = 5, not 4-byte aligned
|
|
frm, _ := NewFrame(buf)
|
|
frm.ValidateSize(&v)
|
|
if !v.HasError() {
|
|
t.Errorf("ValidateSize(malformed ext) = no error; want error")
|
|
}
|
|
v.ErrPop()
|
|
})
|
|
}
|
|
|
|
func FuzzNextExtField(f *testing.F) {
|
|
f.Add(AppendExtField(nil, ExtNTSUniqueID, make([]byte, 16)))
|
|
f.Add(AppendExtField(nil, ExtNTSCookie, make([]byte, 64)))
|
|
two := AppendExtField(nil, ExtNTSUniqueID, make([]byte, 32))
|
|
two = AppendExtField(two, ExtNTSCookie, make([]byte, 8))
|
|
f.Add(two)
|
|
f.Add([]byte{})
|
|
f.Add([]byte{0x01})
|
|
f.Add([]byte{0, 1, 0, 4})
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
|
off := 0
|
|
for off < len(data) {
|
|
field, n, err := NextExtField(data[off:])
|
|
if err != nil {
|
|
return
|
|
}
|
|
if len(field.RawData()) == 0 {
|
|
return
|
|
}
|
|
_ = field.Type()
|
|
_ = field.TotalLen()
|
|
_ = field.Value()
|
|
off += n
|
|
}
|
|
})
|
|
}
|