diff --git a/binding/golang/main.go b/binding/golang/main.go index da5ff1a..c3c4f8a 100644 --- a/binding/golang/main.go +++ b/binding/golang/main.go @@ -25,7 +25,7 @@ import ( func getXdbPath(fileName string) (string, error) { binPath, err := os.Executable() if err != nil { - return "", fmt.Errorf("failed to get executale: %w", err) + return "", fmt.Errorf("failed to get executable: %w", err) } xdbPath := filepath.Join(filepath.Dir(filepath.Dir(filepath.Dir(binPath))), "/data/", fileName) @@ -78,45 +78,49 @@ func createSearcher(dbPath string, cachePolicy string) (*xdb.Searcher, error) { return nil, fmt.Errorf("open xdb file `%s`: %w", dbPath, err) } - defer handle.Close() - // verify the xdb file // @Note: do NOT call it every time you create a searcher since this will slow down the search response. // @see the util.Verify function for details. err = xdb.Verify(handle) if err != nil { + handle.Close() return nil, fmt.Errorf("xdb verify: %w", err) } // auto-detect the ip version from the xdb header header, err := xdb.LoadHeader(handle) if err != nil { + handle.Close() return nil, fmt.Errorf("failed to load header from `%s`: %s", dbPath, err) } version, err := xdb.VersionFromHeader(header) if err != nil { + handle.Close() return nil, fmt.Errorf("failed to detect IP version from `%s`: %s", dbPath, err) } switch cachePolicy { case "nil", "file": - return xdb.NewWithFileOnly(version, dbPath) + return xdb.INewSearcher(version, handle, nil, nil), nil case "vectorIndex": - vIndex, err := xdb.LoadVectorIndexFromFile(dbPath) + vIndex, err := xdb.LoadVectorIndex(handle) if err != nil { + handle.Close() return nil, fmt.Errorf("failed to load vector index from `%s`: %w", dbPath, err) } - return xdb.NewWithVectorIndex(version, dbPath, vIndex) + return xdb.INewSearcher(version, handle, vIndex, nil), nil case "content": - cBuff, err := xdb.LoadContentFromFile(dbPath) + cBuff, err := xdb.LoadContent(handle) + handle.Close() if err != nil { return nil, fmt.Errorf("failed to load content from '%s': %w", dbPath, err) } return xdb.NewWithBuffer(version, cBuff) default: + handle.Close() return nil, fmt.Errorf("invalid cache policy `%s`, options: file/vectorIndex/content", cachePolicy) } } diff --git a/binding/golang/service/config.go b/binding/golang/service/config.go index 5960c3a..bc757ff 100644 --- a/binding/golang/service/config.go +++ b/binding/golang/service/config.go @@ -70,6 +70,7 @@ func newConfig(cachePolicy int, ipVersion *xdb.Version, xdbPath string, searcher if err != nil { return nil, err } + defer handle.Close() // 1, verify the xdb err = xdb.Verify(handle) @@ -90,7 +91,7 @@ func newConfig(cachePolicy int, ipVersion *xdb.Version, xdbPath string, searcher } if xIpVersion.Id != ipVersion.Id { - return nil, fmt.Errorf("ip verison not match: xdb file %s with ip version=%s, as %s expected", xdbPath, xIpVersion.Name, ipVersion.Name) + return nil, fmt.Errorf("ip version not match: xdb file %s with ip version=%s, as %s expected", xdbPath, xIpVersion.Name, ipVersion.Name) } // 3, check and load the vector index buffer diff --git a/binding/golang/service/ip2region.go b/binding/golang/service/ip2region.go index 2120c6f..24af8c6 100644 --- a/binding/golang/service/ip2region.go +++ b/binding/golang/service/ip2region.go @@ -75,7 +75,7 @@ func NewIp2Region(v4Config *Config, v6Config *Config) (*Ip2Region, error) { v6InMemSearcher = nil v6Pool, err = NewSearcherPool(v6Config) if err != nil { - return nil, fmt.Errorf("failed to create v6 in-memeory searcher pool: %w", err) + return nil, fmt.Errorf("failed to create v6 in-memory searcher pool: %w", err) } } diff --git a/binding/golang/service/searcher_pool.go b/binding/golang/service/searcher_pool.go index 44cca41..9d49f53 100644 --- a/binding/golang/service/searcher_pool.go +++ b/binding/golang/service/searcher_pool.go @@ -37,7 +37,7 @@ func NewSearcherPool(config *Config) (*SearcherPool, error) { return nil, fmt.Errorf("config.searchers must > 0") } - pool := make(chan *xdb.Searcher, config.searchers+1) + pool := make(chan *xdb.Searcher, config.searchers) // check and create all the searchers for i := 0; i < config.searchers; i++ { searcher, err := xdb.NewSearcher(config.ipVersion, config.xdbPath, config.vIndex, config.cBuffer) @@ -91,23 +91,27 @@ func (sp *SearcherPool) Close() { func (sp *SearcherPool) CloseTimeout(d time.Duration) { close(sp.closing) + timer := time.NewTimer(d) + defer timer.Stop() + for { - timeout := false select { case s := <-sp.pool: s.Close() - case <-time.After(d): - // check if all the loaned searchers was closed - timeout = true + case <-timer.C: + // hard deadline reached — drain remaining pool items and return + for { + select { + case s := <-sp.pool: + s.Close() + default: + return + } + } } - lc, left := sp.LoanCount(), len(sp.pool) - if left == 0 && lc == 0 { - break - } - - if timeout { - break + if len(sp.pool) == 0 && sp.LoanCount() == 0 { + return } } } diff --git a/binding/golang/service/searcher_pool_test.go b/binding/golang/service/searcher_pool_test.go index af93ad3..a9159d8 100644 --- a/binding/golang/service/searcher_pool_test.go +++ b/binding/golang/service/searcher_pool_test.go @@ -39,6 +39,35 @@ func TestV4SearcherPool(t *testing.T) { searcherPool.Close() } +func TestV4SearcherPoolNoCache(t *testing.T) { + // verify SearcherPool works with NoCache policy (file-based reads) + v4Config, err := NewV4Config(NoCache, "../../../data/ip2region_v4.xdb", 3) + if err != nil { + t.Fatalf("failed to new v4 config: %s", err) + } + + searcherPool, err := NewSearcherPool(v4Config) + if err != nil { + t.Fatalf("failed to create searcher pool: %s", err) + } + + ipString := "219.133.110.197" + for i := 0; i < 5; i++ { + searcher := searcherPool.BorrowSearcher() + region, err := searcher.Search(ipString) + if err != nil { + t.Fatalf("failed to search(%s): %s", ipString, err) + } + if region == "" { + t.Fatalf("search(%s) returned empty region", ipString) + } + fmt.Printf("%2d->search(%s)=%s\n", i, ipString, region) + searcherPool.ReturnSearcher(searcher) + } + + searcherPool.Close() +} + func TestV6SearcherPool(t *testing.T) { v6Config, err := NewV6Config(VIndexCache, "../../../data/ip2region_v6.xdb", 5) if err != nil { diff --git a/binding/golang/xdb/header.go b/binding/golang/xdb/header.go index 0bba957..51bc597 100644 --- a/binding/golang/xdb/header.go +++ b/binding/golang/xdb/header.go @@ -61,8 +61,8 @@ type Header struct { } func NewHeader(input []byte) (*Header, error) { - if len(input) < 16 { - return nil, fmt.Errorf("invalid input buffer") + if len(input) < 20 { + return nil, fmt.Errorf("invalid input buffer, expected at least 20 bytes, got %d", len(input)) } return &Header{ diff --git a/binding/golang/xdb/header_test.go b/binding/golang/xdb/header_test.go new file mode 100644 index 0000000..6c24003 --- /dev/null +++ b/binding/golang/xdb/header_test.go @@ -0,0 +1,56 @@ +// Copyright 2022 The Ip2Region Authors. All rights reserved. +// Use of this source code is governed by a Apache2.0-style +// license that can be found in the LICENSE file. + +package xdb + +import ( + "testing" +) + +func TestNewHeaderShortInput(t *testing.T) { + // input shorter than 20 bytes should be rejected + short := make([]byte, 16) + _, err := NewHeader(short) + if err == nil { + t.Fatal("NewHeader with 16 bytes should fail") + } + + // 20 bytes should be accepted + valid := make([]byte, 20) + _, err = NewHeader(valid) + if err != nil { + t.Fatalf("NewHeader with 20 bytes should succeed: %s", err) + } +} + +func TestNewHeaderParsing(t *testing.T) { + // construct a known 20-byte header + input := []byte{ + 0x03, 0x00, // Version = 3 (LE) + 0x01, 0x00, // IndexPolicy = 1 (LE) + 0x00, 0x00, 0x00, 0x00, // CreatedAt = 0 + 0x00, 0x00, 0x00, 0x00, // StartIndexPtr = 0 + 0x00, 0x00, 0x00, 0x00, // EndIndexPtr = 0 + 0x04, 0x00, // IPVersion = 4 + 0x04, 0x00, // RuntimePtrBytes = 4 + } + + h, err := NewHeader(input) + if err != nil { + t.Fatalf("NewHeader failed: %s", err) + } + + if h.Version != 3 { + t.Errorf("Version = %d, want 3", h.Version) + } + if h.IndexPolicy != 1 { + t.Errorf("IndexPolicy = %d, want 1", h.IndexPolicy) + } + if h.IPVersion != 4 { + t.Errorf("IPVersion = %d, want 4", h.IPVersion) + } + if h.RuntimePtrBytes != 4 { + t.Errorf("RuntimePtrBytes = %d, want 4", h.RuntimePtrBytes) + } +} diff --git a/binding/golang/xdb/searcher.go b/binding/golang/xdb/searcher.go index 87f0f40..22c3f14 100644 --- a/binding/golang/xdb/searcher.go +++ b/binding/golang/xdb/searcher.go @@ -32,6 +32,10 @@ type Searcher struct { // content buffer. // running with the whole xdb file cached contentBuff []byte + + // pre-allocated segment index buffer (file mode only). + // reduces per-query heap allocation. + segBuff []byte } func NewWithFileOnly(version *Version, dbFile string) (*Searcher, error) { @@ -68,6 +72,7 @@ func NewSearcher(version *Version, dbFile string, vIndex []byte, cBuff []byte) ( version: version, dbReader: handle, vectorIndex: vIndex, + segBuff: make([]byte, version.SegmentIndexSize), }, nil } @@ -164,7 +169,14 @@ func (s *Searcher) Search(ip any) (string, error) { var bytes, dBytes = len(ipBytes), len(ipBytes) << 1 var segIndexSize = uint32(s.version.SegmentIndexSize) var dataLen, dataPtr = 0, uint32(0) - var buff = make([]byte, segIndexSize) + // use pre-allocated buffer for file-mode searchers (goroutine-private) + // to avoid per-query heap allocation; fall back for shared content-mode + var buff []byte + if s.segBuff != nil { + buff = s.segBuff + } else { + buff = make([]byte, segIndexSize) + } var l, h = 0, int((ePtr - sPtr) / segIndexSize) for l <= h { m := (l + h) >> 1 diff --git a/binding/golang/xdb/util_test.go b/binding/golang/xdb/util_test.go index 930fcbc..cd5fd4d 100644 --- a/binding/golang/xdb/util_test.go +++ b/binding/golang/xdb/util_test.go @@ -15,6 +15,16 @@ import ( "time" ) +// bytesToInt64 converts a big-endian byte slice (variable length) to int64. +// This handles the carry byte produced by IPSub when the addition overflows 4 bytes. +func bytesToInt64(buf []byte) int64 { + var v int64 + for _, b := range buf { + v = (v << 8) | int64(b) + } + return v +} + func TestParseIP(t *testing.T) { var ips = []string{"29.34.191.255", "2c0f:fff0::", "2fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"} for _, ip := range ips { @@ -51,39 +61,83 @@ func TestIPSub(t *testing.T) { var intToSub = int(binary.BigEndian.Uint32(bytesToSub)) t.Logf("to sub ip: %d -> %s", intToSub, strToSub) + // edge cases + edgeValues := []uint32{0, 1, 2, 0x7FFFFFFF, 0x80000000, 0xFFFFFFFE, 0xFFFFFFFF} + for _, v := range edgeValues { + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, v) + subVal, err := IPSub(buf, bytesToSub) + if err != nil { + t.Fatalf("failed to IPSub(%s,%s): %s", IP2String(buf), strToSub, err) + } + byteSub := bytesToInt64(subVal) + intSub := int64(v) + int64(intToSub) + if byteSub != intSub { + t.Fatalf("IPSub(%d, %d): byte=%d, int=%d", v, intToSub, byteSub, intSub) + } + } + + // stride-based sampling across the full range counter := 0 + const stride = 0x100000 buf := make([]byte, 4) - for i := 0; i < 0x2FFFFFFF; i++ { - binary.BigEndian.PutUint32(buf, uint32(i)) + for i := uint32(0); i < 0xFFFFFFFF-stride; i += stride { + binary.BigEndian.PutUint32(buf, i) subVal, err := IPSub(buf, bytesToSub) if err != nil { t.Fatalf("failed to IPSub(%s,%s): %s", IP2String(buf), strToSub, err) } - // do it as two integers - byteSub := int(binary.BigEndian.Uint32(subVal)) - intSub := i + intToSub + byteSub := bytesToInt64(subVal) + intSub := int64(i) + int64(intToSub) if byteSub != intSub { t.Fatal("byte and int sub value are not the same") } counter++ } + // also test the final boundary value + binary.BigEndian.PutUint32(buf, 0xFFFFFFFF) + subVal, err := IPSub(buf, bytesToSub) + if err != nil { + t.Fatalf("failed to IPSub(%s,%s): %s", IP2String(buf), strToSub, err) + } + byteSub := bytesToInt64(subVal) + intSub := int64(4294967295) + int64(intToSub) + if byteSub != intSub { + t.Fatalf("IPSub(0xFFFFFFFF, %d): byte=%d, int=%d", intToSub, byteSub, intSub) + } + counter++ - t.Logf("test done with %d ips", counter) + t.Logf("test done with %d ips (sampled)", counter) } func TestIPHalf(t *testing.T) { - var buf = make([]byte, 4) - for i := 0; i < 0xFFFFFFFF; i++ { - binary.BigEndian.PutUint32(buf, uint32(i)) + // edge cases + edgeValues := []uint32{0, 1, 2, 0x7FFFFFFF, 0x80000000, 0xFFFFFFFE, 0xFFFFFFFF} + for _, v := range edgeValues { + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, v) + half := IPHalf(buf) + + byteMiddle := binary.BigEndian.Uint32(half) + intMidle := v >> 1 + if byteMiddle != intMidle { + t.Fatalf("IPHalf(0x%08x): byte=0x%08x, int=0x%08x", v, byteMiddle, intMidle) + } + } + + // stride-based sampling across the full range + const stride = 0x100000 + buf := make([]byte, 4) + for i := uint32(0); i < 0xFFFFFFFF-stride; i += stride { + binary.BigEndian.PutUint32(buf, i) half := IPHalf(buf) - // do it as two integers byteMiddle := binary.BigEndian.Uint32(half) intMidle := i >> 1 - if byteMiddle != uint32(intMidle) { - t.Fatal("byte middle and int middle are not the same") + if byteMiddle != intMidle { + t.Fatalf("IPHalf(0x%08x): byte=0x%08x, int=0x%08x", i, byteMiddle, intMidle) } } } @@ -116,18 +170,35 @@ func TestIPMiddle(t *testing.T) { var sInt = int(binary.BigEndian.Uint32(sBytes)) t.Logf("start ip: %d -> %s", sInt, sIPStr) + // edge cases + edgeValues := []uint32{0, 1, 2, 0x7FFFFFFF, 0x80000000, 0xFFFFFFFE, 0xFFFFFFFF} + for _, v := range edgeValues { + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, v) + midVal, err := IPMiddle(sBytes, buf) + if err != nil { + t.Fatalf("failed to IPMiddle(%s,%s): %s", sIPStr, IP2String(buf), err) + } + byteMid := int(binary.BigEndian.Uint32(midVal)) + intMid := (sInt + int(v)) >> 1 + if byteMid != intMid { + t.Fatalf("IPMiddle(%d, %d): byte=%d, int=%d", sInt, v, byteMid, intMid) + } + } + + // stride-based sampling across the full range counter := 0 + const stride = 0x100000 buf := make([]byte, 4) - for i := 0; i < 0x0FFFFFFF; i++ { - binary.BigEndian.PutUint32(buf, uint32(i)) + for i := uint32(0); i < 0xFFFFFFFF-stride; i += stride { + binary.BigEndian.PutUint32(buf, i) midVal, err := IPMiddle(sBytes, buf) if err != nil { t.Fatalf("failed to IPMiddle(%s,%s): %s", sIPStr, IP2String(buf), err) } - // do it as two integers byteMid := int(binary.BigEndian.Uint32(midVal)) - intMid := (sInt + i) >> 1 + intMid := (sInt + int(i)) >> 1 if byteMid != intMid { t.Fatal("byte and int middle value are not the same") } @@ -135,7 +206,7 @@ func TestIPMiddle(t *testing.T) { counter++ } - t.Logf("test done with %d ips", counter) + t.Logf("test done with %d ips (sampled)", counter) } func TestLoadVectorIndex(t *testing.T) { @@ -158,6 +229,73 @@ func TestLoadContent(t *testing.T) { fmt.Printf("buff length: %d\n", len(buff)) } +func TestIPv4CompareCorrectness(t *testing.T) { + tests := []struct { + name string + ip1 []byte // big-endian (from ParseIP) + ip2 []byte // little-endian (from xdb index) + want int + }{ + // equal + {"equal", []byte{1, 2, 3, 4}, []byte{4, 3, 2, 1}, 0}, + {"equal_zero", []byte{0, 0, 0, 0}, []byte{0, 0, 0, 0}, 0}, + {"equal_max", []byte{255, 255, 255, 255}, []byte{255, 255, 255, 255}, 0}, + + // less at each byte position + {"less_b0", []byte{0, 2, 3, 4}, []byte{4, 3, 2, 1}, -1}, + {"less_b1", []byte{1, 1, 3, 4}, []byte{4, 3, 2, 1}, -1}, + {"less_b2", []byte{1, 2, 2, 4}, []byte{4, 3, 2, 1}, -1}, + {"less_b3", []byte{1, 2, 3, 3}, []byte{4, 3, 2, 1}, -1}, + + // greater at each byte position + {"greater_b0", []byte{2, 2, 3, 4}, []byte{4, 3, 2, 1}, 1}, + {"greater_b1", []byte{1, 3, 3, 4}, []byte{4, 3, 2, 1}, 1}, + {"greater_b2", []byte{1, 2, 4, 4}, []byte{4, 3, 2, 1}, 1}, + {"greater_b3", []byte{1, 2, 3, 5}, []byte{4, 3, 2, 1}, 1}, + + // edge: LE ip2 with byte0=0x00 (common for small IPs) + {"le_zero_leading", []byte{1, 0, 0, 1}, []byte{1, 0, 0, 1}, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ip2Copy := make([]byte, 4) + copy(ip2Copy, tc.ip2) + got := IPv4.IPCompare(tc.ip1, tc.ip2) + if got != tc.want { + t.Errorf("IPv4.IPCompare(%v, %v) = %d, want %d", tc.ip1, tc.ip2, got, tc.want) + } + // verify ip2 is not mutated + for i := 0; i < 4; i++ { + if tc.ip2[i] != ip2Copy[i] { + t.Errorf("IPv4.IPCompare mutated ip2 at byte %d: was 0x%02x, now 0x%02x", i, ip2Copy[i], tc.ip2[i]) + } + } + }) + } +} + +func TestIPCompareNoMutation(t *testing.T) { + // verify that IPv4.IPCompare does NOT mutate the input ip2 slice + ip1, _ := ParseIP("1.2.3.4") + ip2Orig := []byte{0x04, 0x03, 0x02, 0x01} // LE representation of 1.2.3.4 + ip2Copy := make([]byte, 4) + copy(ip2Copy, ip2Orig) + + result := IPv4.IPCompare(ip1, ip2Orig) + if result != 0 { + t.Fatalf("IPv4.IPCompare(1.2.3.4, LE[1.2.3.4]) = %d, want 0", result) + } + + // verify ip2 is unchanged + for i := 0; i < 4; i++ { + if ip2Orig[i] != ip2Copy[i] { + t.Fatalf("IPv4.IPCompare mutated ip2 at byte %d: was 0x%02x, now 0x%02x", i, ip2Copy[i], ip2Orig[i]) + } + } + _ = ip2Copy +} + func TestLoadHeader(t *testing.T) { header, err := LoadHeaderFromFile("../../../data/ip2region_v4.xdb") if err != nil { diff --git a/binding/golang/xdb/version.go b/binding/golang/xdb/version.go index c086772..3772740 100644 --- a/binding/golang/xdb/version.go +++ b/binding/golang/xdb/version.go @@ -41,10 +41,33 @@ var ( SegmentIndexSize: 14, // 4 + 4 + 2 + 4, IPCompare: func(ip1, ip2 []byte) int { // ip1 - with Big endian byte order parsed from an input - // ip2 - with Little endian byte order read from the xdb index - ip2[0], ip2[3] = ip2[3], ip2[0] - ip2[1], ip2[2] = ip2[2], ip2[1] - return bytes.Compare(ip1, ip2) + // ip2 - with Little endian byte order read from the xdb index (LE) + // compare without mutating ip2 + if ip1[0] != ip2[3] { + if ip1[0] < ip2[3] { + return -1 + } + return 1 + } + if ip1[1] != ip2[2] { + if ip1[1] < ip2[2] { + return -1 + } + return 1 + } + if ip1[2] != ip2[1] { + if ip1[2] < ip2[1] { + return -1 + } + return 1 + } + if ip1[3] != ip2[0] { + if ip1[3] < ip2[0] { + return -1 + } + return 1 + } + return 0 }, } IPv6 = &Version{ @@ -90,7 +113,7 @@ func VersionFromHeader(header *Header) (*Version, error) { // structure 3.0 after IPv6 supporting if header.Version != Structure30 { - return IPvx, fmt.Errorf("invalid version `%d`", header.IPVersion) + return IPvx, fmt.Errorf("invalid structure version: %d", header.Version) } switch header.IPVersion { @@ -99,6 +122,6 @@ func VersionFromHeader(header *Header) (*Version, error) { case IPv6VersionNo: return IPv6, nil default: - return IPvx, fmt.Errorf("invalid version `%d`", header.Version) + return IPvx, fmt.Errorf("invalid ip version: %d", header.IPVersion) } } diff --git a/binding/golang/xdb/version_test.go b/binding/golang/xdb/version_test.go new file mode 100644 index 0000000..2f3cf9f --- /dev/null +++ b/binding/golang/xdb/version_test.go @@ -0,0 +1,153 @@ +// Copyright 2022 The Ip2Region Authors. All rights reserved. +// Use of this source code is governed by a Apache2.0-style +// license that can be found in the LICENSE file. + +package xdb + +import ( + "testing" +) + +func TestVersionFromHeader(t *testing.T) { + // Structure20 -> always IPv4 (legacy format) + h := &Header{Version: Structure20} + v, err := VersionFromHeader(h) + if err != nil { + t.Fatalf("VersionFromHeader(Structure20) unexpected error: %s", err) + } + if v != IPv4 { + t.Fatalf("VersionFromHeader(Structure20) = %s, want IPv4", v) + } + + // Structure30 + IPv4VersionNo + h = &Header{Version: Structure30, IPVersion: IPv4VersionNo} + v, err = VersionFromHeader(h) + if err != nil { + t.Fatalf("VersionFromHeader(Structure30,IPv4) unexpected error: %s", err) + } + if v != IPv4 { + t.Fatalf("VersionFromHeader(Structure30,IPv4) = %s, want IPv4", v) + } + + // Structure30 + IPv6VersionNo + h = &Header{Version: Structure30, IPVersion: IPv6VersionNo} + v, err = VersionFromHeader(h) + if err != nil { + t.Fatalf("VersionFromHeader(Structure30,IPv6) unexpected error: %s", err) + } + if v != IPv6 { + t.Fatalf("VersionFromHeader(Structure30,IPv6) = %s, want IPv6", v) + } + + // invalid structure version + h = &Header{Version: 999} + _, err = VersionFromHeader(h) + if err == nil { + t.Fatal("VersionFromHeader(invalid version) should return error") + } + + // Structure30 + invalid IP version + h = &Header{Version: Structure30, IPVersion: 999} + _, err = VersionFromHeader(h) + if err == nil { + t.Fatal("VersionFromHeader(Structure30,invalid IPVersion) should return error") + } +} + +func TestVersionFromName(t *testing.T) { + tests := []struct { + name string + want *Version + ok bool + }{ + {"v4", IPv4, true}, + {"V4", IPv4, true}, + {"ipv4", IPv4, true}, + {"IPv4", IPv4, true}, + {"v6", IPv6, true}, + {"V6", IPv6, true}, + {"ipv6", IPv6, true}, + {"IPv6", IPv6, true}, + {"ipv4", IPv4, true}, + {"IPV6", IPv6, true}, + {"", nil, false}, + {"invalid", nil, false}, + {"v7", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v, err := VersionFromName(tc.name) + if tc.ok { + if err != nil { + t.Fatalf("VersionFromName(%q) unexpected error: %s", tc.name, err) + } + if v != tc.want { + t.Fatalf("VersionFromName(%q) = %s, want %s", tc.name, v, tc.want) + } + } else { + if err == nil { + t.Fatalf("VersionFromName(%q) should return error", tc.name) + } + } + }) + } +} + +func TestVersionFromIP(t *testing.T) { + tests := []struct { + ip string + want *Version + ok bool + }{ + {"1.2.3.4", IPv4, true}, + {"0.0.0.0", IPv4, true}, + {"255.255.255.255", IPv4, true}, + {"::1", IPv6, true}, + {"240e:3b7::1", IPv6, true}, + {"", nil, false}, + {"not-an-ip", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.ip, func(t *testing.T) { + v, err := VersionFromIP(tc.ip) + if tc.ok { + if err != nil { + t.Fatalf("VersionFromIP(%q) unexpected error: %s", tc.ip, err) + } + if v != tc.want { + t.Fatalf("VersionFromIP(%q) = %s, want %s", tc.ip, v, tc.want) + } + } else { + if err == nil { + t.Fatalf("VersionFromIP(%q) should return error", tc.ip) + } + } + }) + } +} + +func TestIPv6Compare(t *testing.T) { + // IPv6.IPCompare uses bytes.Compare directly, no byte-order swapping needed + tests := []struct { + name string + ip1 []byte + ip2 []byte + want int + }{ + {"equal", []byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, []byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, 0}, + {"less_b0", []byte{0x1f, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, []byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, -1}, + {"greater_b0", []byte{0x21, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, []byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, 1}, + {"zero_vs_max", []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, -1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := IPv6.IPCompare(tc.ip1, tc.ip2) + if got != tc.want { + t.Fatalf("IPv6.IPCompare(%v, %v) = %d, want %d", tc.ip1, tc.ip2, got, tc.want) + } + }) + } +}