mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
merge: merge branch 'feat/cordis-router-raw-routes' into main
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package extpoints
|
package extpoints
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由,
|
||||||
|
// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。
|
||||||
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
|
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
|
||||||
r := &RouterRegistry{}
|
r := &RouterRegistry{}
|
||||||
g := r.Group("/api/v1/nodes")
|
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/")
|
t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/")
|
||||||
}
|
}
|
||||||
if slashed.ID == slashless.ID {
|
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 {
|
if got := len(r.Routes()); got != 2 {
|
||||||
t.Errorf("registry routes = %d, want 2", got)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+28
-7
@@ -143,7 +143,10 @@ func (c *Cache) Set(key string, value []byte, ttl time.Duration) error {
|
|||||||
|
|
||||||
// Update memory tracker
|
// Update memory tracker
|
||||||
if elem, ok := c.items[key]; ok {
|
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
|
c.currentSize += size - item.size
|
||||||
item.size = size
|
item.size = size
|
||||||
item.expiredAt = expiredAt
|
item.expiredAt = expiredAt
|
||||||
@@ -174,7 +177,11 @@ func (c *Cache) Get(key string) ([]byte, error) {
|
|||||||
return nil, ErrCacheMiss
|
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) {
|
if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) {
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
return c.getAndDeleteIfExpired(key)
|
return c.getAndDeleteIfExpired(key)
|
||||||
@@ -224,7 +231,11 @@ func (c *Cache) getAndDeleteIfExpired(key string) ([]byte, error) {
|
|||||||
return nil, ErrCacheMiss
|
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) {
|
if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) {
|
||||||
_ = c.deleteUnlocked(key)
|
_ = c.deleteUnlocked(key)
|
||||||
return nil, ErrCacheMiss
|
return nil, ErrCacheMiss
|
||||||
@@ -254,8 +265,9 @@ func (c *Cache) Delete(key string) error {
|
|||||||
|
|
||||||
func (c *Cache) deleteUnlocked(key string) error {
|
func (c *Cache) deleteUnlocked(key string) error {
|
||||||
if elem, ok := c.items[key]; ok {
|
if elem, ok := c.items[key]; ok {
|
||||||
item := elem.Value.(*cacheItem)
|
if item, ok := elem.Value.(*cacheItem); ok {
|
||||||
c.currentSize -= item.size
|
c.currentSize -= item.size
|
||||||
|
}
|
||||||
c.evictList.Remove(elem)
|
c.evictList.Remove(elem)
|
||||||
delete(c.items, key)
|
delete(c.items, key)
|
||||||
}
|
}
|
||||||
@@ -308,7 +320,11 @@ func (c *Cache) evict() {
|
|||||||
|
|
||||||
for c.currentSize > c.maxSize && c.evictList.Len() > 0 {
|
for c.currentSize > c.maxSize && c.evictList.Len() > 0 {
|
||||||
elem := c.evictList.Back()
|
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.currentSize -= item.size
|
||||||
c.evictList.Remove(elem)
|
c.evictList.Remove(elem)
|
||||||
delete(c.items, item.key)
|
delete(c.items, item.key)
|
||||||
@@ -400,7 +416,12 @@ func (c *Cache) cleanExpired() {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for key, elem := range c.items {
|
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) {
|
if !item.expiredAt.IsZero() && now.After(item.expiredAt) {
|
||||||
c.currentSize -= item.size
|
c.currentSize -= item.size
|
||||||
c.evictList.Remove(elem)
|
c.evictList.Remove(elem)
|
||||||
|
|||||||
+63
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user