merge: merge branch 'feat/cordis-router-raw-routes' into main

This commit is contained in:
ryan
2026-08-30 17:57:38 +08:00
12 changed files with 673 additions and 8 deletions
+26 -1
View File
@@ -1,7 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "testing"
// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由,
// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
r := &RouterRegistry{}
g := r.Group("/api/v1/nodes")
@@ -19,9 +24,29 @@ func TestHandleRawPreservesTrailingSlash(t *testing.T) {
t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/")
}
if slashed.ID == slashless.ID {
t.Error("HandleRaw must allocate its own route ID")
t.Error("HandleRaw must allocate its own route ID so scoped teardown can unregister both")
}
if got := len(r.Routes()); got != 2 {
t.Errorf("registry routes = %d, want 2", got)
}
if !r.UnregisterByID(slashed.ID) {
t.Error("UnregisterByID(HandleRaw route) = false, want true")
}
if got := len(r.Routes()); got != 1 {
t.Errorf("routes after unregister = %d, want 1", got)
}
}
// TestRegistryHandleRawKeepsAbsolutePath 根注册表上 HandleRaw 只做绝对化处理。
func TestRegistryHandleRawKeepsAbsolutePath(t *testing.T) {
r := &RouterRegistry{}
if got := r.HandleRaw("GET", "/health/").Path; got != "/health/" {
t.Errorf("path = %q, want %q", got, "/health/")
}
if got := r.HandleRaw("POST", "submit").Path; got != "/submit" {
t.Errorf("path = %q, want %q", got, "/submit")
}
if got := r.BasePath(); got != "" {
t.Errorf("registry BasePath() = %q, want empty", got)
}
}
+28 -7
View File
@@ -143,7 +143,10 @@ func (c *Cache) Set(key string, value []byte, ttl time.Duration) error {
// Update memory tracker
if elem, ok := c.items[key]; ok {
item := elem.Value.(*cacheItem)
item, ok := elem.Value.(*cacheItem)
if !ok {
return fmt.Errorf("cache: evict list entry for %q has invalid type %T", key, elem.Value)
}
c.currentSize += size - item.size
item.size = size
item.expiredAt = expiredAt
@@ -174,7 +177,11 @@ func (c *Cache) Get(key string) ([]byte, error) {
return nil, ErrCacheMiss
}
item := elem.Value.(*cacheItem)
item, ok := elem.Value.(*cacheItem)
if !ok {
c.mu.RUnlock()
return nil, ErrCacheMiss
}
if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) {
c.mu.RUnlock()
return c.getAndDeleteIfExpired(key)
@@ -224,7 +231,11 @@ func (c *Cache) getAndDeleteIfExpired(key string) ([]byte, error) {
return nil, ErrCacheMiss
}
item := elem.Value.(*cacheItem)
item, ok := elem.Value.(*cacheItem)
if !ok {
_ = c.deleteUnlocked(key)
return nil, ErrCacheMiss
}
if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) {
_ = c.deleteUnlocked(key)
return nil, ErrCacheMiss
@@ -254,8 +265,9 @@ func (c *Cache) Delete(key string) error {
func (c *Cache) deleteUnlocked(key string) error {
if elem, ok := c.items[key]; ok {
item := elem.Value.(*cacheItem)
c.currentSize -= item.size
if item, ok := elem.Value.(*cacheItem); ok {
c.currentSize -= item.size
}
c.evictList.Remove(elem)
delete(c.items, key)
}
@@ -308,7 +320,11 @@ func (c *Cache) evict() {
for c.currentSize > c.maxSize && c.evictList.Len() > 0 {
elem := c.evictList.Back()
item := elem.Value.(*cacheItem)
item, ok := elem.Value.(*cacheItem)
if !ok {
c.evictList.Remove(elem)
continue
}
c.currentSize -= item.size
c.evictList.Remove(elem)
delete(c.items, item.key)
@@ -400,7 +416,12 @@ func (c *Cache) cleanExpired() {
now := time.Now()
for key, elem := range c.items {
item := elem.Value.(*cacheItem)
item, ok := elem.Value.(*cacheItem)
if !ok {
c.evictList.Remove(elem)
delete(c.items, key)
continue
}
if !item.expiredAt.IsZero() && now.After(item.expiredAt) {
c.currentSize -= item.size
c.evictList.Remove(elem)
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package disk
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// 这些用例锁住「LRU 链表节点被污染时不得 panic」的行为:一旦 items 与 evictList
// 的不变量被破坏(例如后续改动误写节点),缓存必须降级为未命中/跳过,
// 而不是在读、写、删除与淘汰路径上崩掉整个进程。
// corruptEntry 写入一个键后把其链表节点值换成非法类型,返回缓存。
func corruptEntry(t *testing.T, key string) *Cache {
t.Helper()
c := New(t.TempDir())
require.NoError(t, c.Set(key, []byte("payload"), time.Minute))
elem, ok := c.items[key]
require.True(t, ok, "entry must be tracked after Set")
elem.Value = "not-a-cacheItem"
return c
}
func TestGetToleratesCorruptEvictEntry(t *testing.T) {
c := corruptEntry(t, "k")
got, err := c.Get("k")
require.ErrorIs(t, err, ErrCacheMiss)
require.Nil(t, got)
}
func TestSetOverCorruptEvictEntryReportsError(t *testing.T) {
c := corruptEntry(t, "k")
err := c.Set("k", []byte("second"), time.Minute)
require.Error(t, err, "Set must report the corrupted tracker entry instead of panicking")
require.Contains(t, err.Error(), "invalid type")
}
func TestDeleteToleratesCorruptEvictEntry(t *testing.T) {
c := corruptEntry(t, "k")
require.NotPanics(t, func() { _ = c.Delete("k") })
require.NotContains(t, c.items, "k")
}
func TestEvictToleratesCorruptEvictEntry(t *testing.T) {
c := corruptEntry(t, "k")
// 让任意写入都触发淘汰扫描:扫到被污染的节点必须跳过而非 panic。
c.UpdatePolicy(0, 0, true)
require.NotPanics(t, func() {
for i := range 4 {
_ = c.Set(string(rune('a'+i)), []byte("x"), time.Minute)
}
})
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package util provides shared formatting and string helper functions.
package util
import (
"fmt"
"strconv"
)
const (
secondsPerYear = 31104000 // 360 days
secondsPerMonth = 2592000 // 30 days
secondsPerDay = 86400
secondsPerHour = 3600
secondsPerMinute = 60
)
const (
sizeKB = 1024
sizeMB = sizeKB * 1024
sizeGB = sizeMB * 1024
)
// Bytes2Size converts a byte count to a human-readable string with unit (B, KB, MB, GB).
func Bytes2Size(num int64) string {
var numStr string
unit := "B"
switch {
case num/int64(sizeGB) >= 1:
numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
unit = "GB"
case num/int64(sizeMB) >= 1:
numStr = strconv.Itoa(int(float64(num) / float64(sizeMB)))
unit = "MB"
case num/int64(sizeKB) >= 1:
numStr = strconv.Itoa(int(float64(num) / float64(sizeKB)))
unit = "KB"
default:
numStr = strconv.FormatInt(num, 10)
}
return numStr + " " + unit
}
// Seconds2Time converts a number of seconds to a human-readable Chinese duration string.
func Seconds2Time(num int) (time string) {
if num/secondsPerYear > 0 {
time += strconv.Itoa(num/secondsPerYear) + " 年 "
num %= secondsPerYear
}
if num/secondsPerMonth > 0 {
time += strconv.Itoa(num/secondsPerMonth) + " 个月 "
num %= secondsPerMonth
}
if num/secondsPerDay > 0 {
time += strconv.Itoa(num/secondsPerDay) + " 天 "
num %= secondsPerDay
}
if num/secondsPerHour > 0 {
time += strconv.Itoa(num/secondsPerHour) + " 小时 "
num %= secondsPerHour
}
if num/secondsPerMinute > 0 {
time += strconv.Itoa(num/secondsPerMinute) + " 分钟 "
num %= secondsPerMinute
}
time += strconv.Itoa(num) + " 秒"
return
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"testing"
)
func TestBytes2Size(t *testing.T) {
tests := []struct {
input int64
expected string
}{
{0, "0 B"},
{500, "500 B"},
{1023, "1023 B"},
{1024, "1 KB"},
{2048, "2 KB"},
{1024 * 1024, "1 MB"},
{1024 * 1024 * 1024, "1.00 GB"},
{1024 * 1024 * 1024 * 2, "2.00 GB"},
}
for _, tt := range tests {
result := Bytes2Size(tt.input)
if result != tt.expected {
t.Errorf("Bytes2Size(%d) = %q, expected %q", tt.input, result, tt.expected)
}
}
}
func TestSeconds2Time(t *testing.T) {
tests := []struct {
input int
expected string
}{
{0, "0 秒"},
{30, "30 秒"},
{60, "1 分钟 0 秒"},
{125, "2 分钟 5 秒"},
{3600, "1 小时 0 秒"},
{86400, "1 天 0 秒"},
}
for _, tt := range tests {
result := Seconds2Time(tt.input)
if result != tt.expected {
t.Errorf("Seconds2Time(%d) = %q, expected %q", tt.input, result, tt.expected)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"log/slog"
"net"
)
// GetIP returns the first private IPv4 address found on the local network interfaces.
func GetIP() (ip string) {
ips, err := net.InterfaceAddrs()
if err != nil {
slog.Error("get interface addresses failed", "error", err)
return ip
}
for _, a := range ips {
if candidate, ok := privateIPv4FromAddr(a); ok {
return candidate
}
}
return
}
func privateIPv4FromAddr(addr net.Addr) (string, bool) {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP.IsLoopback() || ipNet.IP.To4() == nil {
return "", false
}
ip := ipNet.IP.String()
if isPrivateIPv4(ip) {
return ip, true
}
return "", false
}
func isPrivateIPv4(ip string) bool {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return false
}
return parsedIP.IsPrivate()
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"testing"
)
func TestIsPrivateIPv4(t *testing.T) {
tests := []struct {
ip string
expected bool
}{
{"127.0.0.1", false}, // Loopback is not in RFC 1918 private range
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.1.1", true},
{"8.8.8.8", false},
{"invalid-ip", false},
}
for _, tt := range tests {
result := isPrivateIPv4(tt.ip)
if result != tt.expected {
t.Errorf("isPrivateIPv4(%q) = %v, expected %v", tt.ip, result, tt.expected)
}
}
}
func TestGetIP(t *testing.T) {
ip := GetIP()
// GetIP should return empty if no private IPv4 address is configured, or a valid IP.
// We just ensure it doesn't panic.
t.Logf("GetIP returned: %q", ip)
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"sort"
"strings"
"time"
)
// Unique returns a new slice containing only the unique elements of the input slice,
// preserving their original order.
func Unique[T comparable](slice []T) []T {
if slice == nil {
return nil
}
seen := make(map[T]struct{})
result := make([]T, 0)
for _, item := range slice {
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
// UniqueAndCleanStringSlice trims spaces, removes empty elements, and returns only the unique elements
// of the input string slice. It preserves order and returns nil if the resulting slice is empty.
func UniqueAndCleanStringSlice(slice []string) []string {
if slice == nil {
return nil
}
seen := make(map[string]struct{})
result := make([]string, 0)
for _, item := range slice {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
// IdentifiableTimeRecord represents a database record that has a unique ID and a primary timestamp field.
type IdentifiableTimeRecord interface {
GetID() uint
GetTime() time.Time
}
// SortAndLimitRecords sorts a slice of IdentifiableTimeRecord descendingly by their timestamp (and ID as a tie-breaker),
// and limits the slice to the specified size if limit > 0.
func SortAndLimitRecords[T IdentifiableTimeRecord](rows []T, limit int) []T {
if len(rows) == 0 {
return rows
}
sort.Slice(rows, func(i, j int) bool {
ti := rows[i].GetTime()
tj := rows[j].GetTime()
if ti.Equal(tj) {
return rows[i].GetID() > rows[j].GetID()
}
return ti.After(tj)
})
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
}
return rows
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import "strings"
// TrimStringFields trims leading and trailing spaces from all provided string pointers.
func TrimStringFields(fields ...*string) {
for _, f := range fields {
if f != nil {
*f = strings.TrimSpace(*f)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"fmt"
"strconv"
)
// Interface2String converts a string, int, or float64 value to its string representation.
func Interface2String(inter any) string {
switch v := inter.(type) {
case string:
return v
case int:
return strconv.Itoa(v)
case float64:
return fmt.Sprintf("%f", v)
}
return "Not Implemented"
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"strconv"
"strings"
)
const gitDescribeMinIdentifiers = 2
// VersionInfo holds the parsed components of a semantic version string.
type VersionInfo struct {
Valid bool
IsDev bool
Numbers []int
Prerelease []string
GitDescribeDistance int
GitDescribeTail []string
}
// ParseVersionInfo parses a version string into a structured VersionInfo.
func ParseVersionInfo(version string) VersionInfo {
normalized := strings.TrimSpace(strings.TrimPrefix(version, "v"))
if normalized == "" || normalized == "dev" {
return VersionInfo{IsDev: strings.EqualFold(normalized, "dev")}
}
base := normalized
prerelease := ""
if separator := strings.IndexRune(normalized, '-'); separator >= 0 {
base = normalized[:separator]
prerelease = normalized[separator+1:]
}
segments := strings.Split(base, ".")
parts := make([]int, 0, len(segments))
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
parts = append(parts, 0)
continue
}
numeric := strings.Builder{}
for _, r := range segment {
if r < '0' || r > '9' {
break
}
numeric.WriteRune(r)
}
if numeric.Len() == 0 {
parts = append(parts, 0)
continue
}
value, err := strconv.Atoi(numeric.String())
if err != nil {
return VersionInfo{}
}
parts = append(parts, value)
}
info := VersionInfo{Valid: len(parts) > 0, Numbers: parts}
if prerelease != "" {
identifiers := splitPrereleaseIdentifiers(prerelease)
if distance, tail, ok := parseGitDescribeIdentifiers(identifiers); ok {
info.GitDescribeDistance = distance
info.GitDescribeTail = tail
} else {
info.Prerelease = identifiers
}
}
return info
}
func parseGitDescribeIdentifiers(identifiers []string) (int, []string, bool) {
if len(identifiers) < gitDescribeMinIdentifiers {
return 0, nil, false
}
distance, err := strconv.Atoi(strings.TrimSpace(identifiers[0]))
if err != nil || distance <= 0 {
return 0, nil, false
}
commitToken := strings.TrimSpace(identifiers[1])
if commitToken == "" || !strings.HasPrefix(strings.ToLower(commitToken), "g") {
return 0, nil, false
}
return distance, identifiers[1:], true
}
func splitPrereleaseIdentifiers(value string) []string {
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
return r == '.' || r == '-'
})
filtered := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
filtered = append(filtered, part)
}
}
return filtered
}
// CompareVersions compares two version strings.
// Returns -1 if left < right, 1 if left > right, and 0 if they are equal.
func CompareVersions(local, remote string) int {
left := ParseVersionInfo(local)
right := ParseVersionInfo(remote)
if left.IsDev {
if right.Valid {
return -1
}
return 0
}
if !left.Valid || !right.Valid {
return 0
}
if result := compareVersionNumbers(left, right); result != 0 {
return result
}
if result := compareGitDescribeDistance(left, right); result != 0 {
return result
}
if left.GitDescribeDistance > 0 || right.GitDescribeDistance > 0 {
return compareGitDescribeTails(left, right)
}
return comparePrereleaseIdentifiers(left, right)
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import "strconv"
func compareVersionNumbers(left, right VersionInfo) int {
maxLen := max(len(right.Numbers), len(left.Numbers))
for index := range maxLen {
leftValue := 0
rightValue := 0
if index < len(left.Numbers) {
leftValue = left.Numbers[index]
}
if index < len(right.Numbers) {
rightValue = right.Numbers[index]
}
if leftValue < rightValue {
return -1
}
if leftValue > rightValue {
return 1
}
}
return 0
}
func compareGitDescribeDistance(left, right VersionInfo) int {
if left.GitDescribeDistance == right.GitDescribeDistance {
return 0
}
if left.GitDescribeDistance < right.GitDescribeDistance {
return -1
}
return 1
}
func compareGitDescribeTails(left, right VersionInfo) int {
maxLen := max(len(right.GitDescribeTail), len(left.GitDescribeTail))
for index := range maxLen {
if index >= len(left.GitDescribeTail) {
return -1
}
if index >= len(right.GitDescribeTail) {
return 1
}
if left.GitDescribeTail[index] < right.GitDescribeTail[index] {
return -1
}
if left.GitDescribeTail[index] > right.GitDescribeTail[index] {
return 1
}
}
return 0
}
func comparePrereleaseIdentifiers(left, right VersionInfo) int {
if len(left.Prerelease) == 0 && len(right.Prerelease) == 0 {
return 0
}
if len(left.Prerelease) == 0 {
return 1
}
if len(right.Prerelease) == 0 {
return -1
}
maxLen := max(len(right.Prerelease), len(left.Prerelease))
for index := range maxLen {
if index >= len(left.Prerelease) {
return -1
}
if index >= len(right.Prerelease) {
return 1
}
if result := comparePrereleasePart(left.Prerelease[index], right.Prerelease[index]); result != 0 {
return result
}
}
return 0
}
func comparePrereleasePart(leftPart, rightPart string) int {
leftNumber, leftErr := strconv.Atoi(leftPart)
rightNumber, rightErr := strconv.Atoi(rightPart)
switch {
case leftErr == nil && rightErr == nil:
if leftNumber < rightNumber {
return -1
}
if leftNumber > rightNumber {
return 1
}
case leftErr == nil:
return -1
case rightErr == nil:
return 1
default:
if leftPart < rightPart {
return -1
}
if leftPart > rightPart {
return 1
}
}
return 0
}