From cb339ab0dc5d7fa689ccba98adcd4c66c9c54fed Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:22:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(core):=20RouterExtension=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20HandleRaw=20=E4=B8=8E=20BasePath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle 经 cleanPath 归一化会剥掉尾部斜杠,插件无法同时声明 /resource 与 /resource/ 两条路由;部署关闭 gin 的 RedirectTrailingSlash 时,缺失的那条 直接 404。下游 OpenFlare 有 20 个历史列表接口依赖该行为。 - HandleRaw:与组前缀拼接但保留尾部斜杠,分配独立路由 ID; - BasePath:返回组的绝对前缀(根注册表为空串); - 作用域包装器为 HandleRaw 同样登记 OnDispose 反注册。 验证:go build 通过;go test ./... exit 0(48 包 ok); check_cordis_architecture.sh 0 violations;gofmt 干净。 --- backend/core/extpoints/router.go | 69 +++++++++++++++++++++++ backend/core/extpoints/router_raw_test.go | 52 +++++++++++++++++ backend/core/scoped_extpoints.go | 14 +++++ 3 files changed, 135 insertions(+) create mode 100644 backend/core/extpoints/router_raw_test.go diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index ac3266c3..f7c281c1 100644 --- a/backend/core/extpoints/router.go +++ b/backend/core/extpoints/router.go @@ -22,6 +22,11 @@ type RouterExtension interface { Use(middlewares ...any) Group(prefix string, middlewares ...any) RouterExtension Handle(method, path string, handlers ...any) RouteDefinition + // HandleRaw joins path with the group prefix but preserves a trailing slash, + // so `/resource` and `/resource/` can coexist as distinct routes. + HandleRaw(method, path string, handlers ...any) RouteDefinition + // BasePath reports this group's absolute prefix ("" for the root registry). + BasePath() string GET(path string, handlers ...any) RouteDefinition POST(path string, handlers ...any) RouteDefinition PUT(path string, handlers ...any) RouteDefinition @@ -503,3 +508,67 @@ func (w *PathWhitelist) Patterns() []string { } return res } + +// ─── Raw path registration ──────────────────────────────────────────────────── + +// ensureLeadingSlash normalises a path to start with exactly one "/" while +// preserving any trailing slash (unlike cleanPath). +func ensureLeadingSlash(p string) string { + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + return "/" + p + } + return p +} + +// joinPathPreservingTrailing joins a group prefix and a relative path without +// stripping a trailing slash, so a group "/x" can serve both "/x" and "/x/". +func joinPathPreservingTrailing(base, relative string) string { + rel := ensureLeadingSlash(relative) + if base == "" || base == "/" { + return rel + } + return strings.TrimSuffix(cleanPath(base), "/") + rel +} + +// HandleRaw registers a route on the root registry, preserving a trailing slash. +func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition { + r.mu.Lock() + defer r.mu.Unlock() + + r.nextID++ + rd := RouteDefinition{ + ID: r.nextID, + Method: strings.ToUpper(method), + Path: ensureLeadingSlash(path), + Handlers: handlers, + Middlewares: append([]any(nil), r.middlewares...), + } + r.routes = append(r.routes, rd) + return rd +} + +// BasePath returns "" because the root registry has no prefix. +func (r *RouterRegistry) BasePath() string { return "" } + +// HandleRaw registers a route under this group, preserving a trailing slash. +func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition { + g.registry.mu.Lock() + defer g.registry.mu.Unlock() + + g.registry.nextID++ + rd := RouteDefinition{ + ID: g.registry.nextID, + Method: strings.ToUpper(method), + Path: joinPathPreservingTrailing(g.prefix, path), + } + rd.Handlers = handlers + rd.Middlewares = append(g.registry.middlewares, g.middlewares...) + g.registry.routes = append(g.registry.routes, rd) + return rd +} + +// BasePath returns this group's absolute prefix. +func (g *RouterGroup) BasePath() string { return g.prefix } diff --git a/backend/core/extpoints/router_raw_test.go b/backend/core/extpoints/router_raw_test.go new file mode 100644 index 00000000..31b0f38f --- /dev/null +++ b/backend/core/extpoints/router_raw_test.go @@ -0,0 +1,52 @@ +// 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") + + if got := g.BasePath(); got != "/api/v1/nodes" { + t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes") + } + slashless := g.Handle("GET", "") + slashed := g.HandleRaw("GET", "/") + + if slashless.Path != "/api/v1/nodes" { + t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes") + } + if slashed.Path != "/api/v1/nodes/" { + 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 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) + } +} diff --git a/backend/core/scoped_extpoints.go b/backend/core/scoped_extpoints.go index 9bd4d923..f567544a 100644 --- a/backend/core/scoped_extpoints.go +++ b/backend/core/scoped_extpoints.go @@ -40,6 +40,20 @@ func (s *scopedRouterExtension) Handle(method, path string, handlers ...any) ext return rd } +// HandleRaw registers a trailing-slash-preserving route and tears it down with the scope. +func (s *scopedRouterExtension) HandleRaw(method, path string, handlers ...any) extpoints.RouteDefinition { + rd := s.underlying.HandleRaw(method, path, handlers...) + routeID := rd.ID + s.ctx.OnDispose(func() error { + s.underlying.UnregisterByID(routeID) + return nil + }) + return rd +} + +// BasePath delegates to the wrapped group prefix. +func (s *scopedRouterExtension) BasePath() string { return s.underlying.BasePath() } + func (s *scopedRouterExtension) GET(path string, handlers ...any) extpoints.RouteDefinition { return s.Handle("GET", path, handlers...) } From bad6fa785de5424f60f4117a7de115b9707d42f1 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:26:01 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(core):=20Handle=20=E4=B8=8E=20Hand?= =?UTF-8?q?leRaw=20=E5=85=B1=E7=94=A8=20addRoute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除注册逻辑重复,并修正 HandleRaw 里 append(g.registry.middlewares, ...) 复用底层数组的隐患:中间件快照统一在 addRoute 内构造为新切片。 验证:go build 通过;go test ./core/... 全绿;golangci-lint ./core/... 0 issues。 --- backend/core/extpoints/router.go | 42 ++++++++++---------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index f7c281c1..44258028 100644 --- a/backend/core/extpoints/router.go +++ b/backend/core/extpoints/router.go @@ -85,6 +85,11 @@ func (r *RouterRegistry) Group(prefix string, middlewares ...any) RouterExtensio // Handle registers a route with a custom HTTP method and handlers. func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition { + return r.addRoute(method, cleanPath(path), handlers...) +} + +// addRoute appends a route whose path is already normalised. +func (r *RouterRegistry) addRoute(method, fullPath string, handlers ...any) RouteDefinition { r.mu.Lock() defer r.mu.Unlock() @@ -92,7 +97,7 @@ func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefin rd := RouteDefinition{ ID: r.nextID, Method: strings.ToUpper(method), - Path: cleanPath(path), + Path: fullPath, Handlers: handlers, Middlewares: append([]any(nil), r.middlewares...), } @@ -228,11 +233,14 @@ func (g *RouterGroup) Group(prefix string, middlewares ...any) RouterExtension { // Handle registers a route under this group. func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition { + return g.addRoute(method, joinPaths(g.prefix, path), handlers...) +} + +// addRoute appends a route under this group whose path is already joined. +func (g *RouterGroup) addRoute(method, fullPath string, handlers ...any) RouteDefinition { g.registry.mu.Lock() defer g.registry.mu.Unlock() - fullPath := joinPaths(g.prefix, path) - allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares)) allMiddlewares = append(allMiddlewares, g.registry.middlewares...) allMiddlewares = append(allMiddlewares, g.middlewares...) @@ -535,19 +543,7 @@ func joinPathPreservingTrailing(base, relative string) string { // HandleRaw registers a route on the root registry, preserving a trailing slash. func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition { - r.mu.Lock() - defer r.mu.Unlock() - - r.nextID++ - rd := RouteDefinition{ - ID: r.nextID, - Method: strings.ToUpper(method), - Path: ensureLeadingSlash(path), - Handlers: handlers, - Middlewares: append([]any(nil), r.middlewares...), - } - r.routes = append(r.routes, rd) - return rd + return r.addRoute(method, ensureLeadingSlash(path), handlers...) } // BasePath returns "" because the root registry has no prefix. @@ -555,19 +551,7 @@ func (r *RouterRegistry) BasePath() string { return "" } // HandleRaw registers a route under this group, preserving a trailing slash. func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition { - g.registry.mu.Lock() - defer g.registry.mu.Unlock() - - g.registry.nextID++ - rd := RouteDefinition{ - ID: g.registry.nextID, - Method: strings.ToUpper(method), - Path: joinPathPreservingTrailing(g.prefix, path), - } - rd.Handlers = handlers - rd.Middlewares = append(g.registry.middlewares, g.middlewares...) - g.registry.routes = append(g.registry.routes, rd) - return rd + return g.addRoute(method, joinPathPreservingTrailing(g.prefix, path), handlers...) } // BasePath returns this group's absolute prefix. From 8ff017b5e81934ae6f056358609f89f5105829f6 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:36:13 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(pkg/util):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=AF=94=E8=BE=83=E3=80=81=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E4=B8=8E=E6=A0=BC=E5=BC=8F=E5=8C=96=E9=80=9A=E7=94=A8=E5=8A=A9?= =?UTF-8?q?=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下游 OpenFlare 的边缘守护进程与发布流程需要这些与业务无关的纯函数, 按上游/下游归属规约回流到平台层,避免下游在上游目录里长期携带本地文件: - version / version_compare:CompareVersions、ParseVersionInfo(版本区间比较) - network:GetIP、IsPrivateIPv4 - format / value / string / slice:Bytes2Size、Seconds2Time、Interface2String、 TrimStringFields、UniqueAndCleanStringSlice 与 IdentifiableTimeRecord 验证:go build 通过;go test ./... exit 0(48 包 ok); check_cordis_architecture.sh 0 violations;golangci-lint 0 issues;gofmt 干净。 --- backend/pkg/util/format.go | 70 +++++++++++++++ backend/pkg/util/format_test.go | 52 +++++++++++ backend/pkg/util/network.go | 45 ++++++++++ backend/pkg/util/network_test.go | 36 ++++++++ backend/pkg/util/slice.go | 79 +++++++++++++++++ backend/pkg/util/string.go | 15 ++++ backend/pkg/util/value.go | 22 +++++ backend/pkg/util/version.go | 129 ++++++++++++++++++++++++++++ backend/pkg/util/version_compare.go | 108 +++++++++++++++++++++++ 9 files changed, 556 insertions(+) create mode 100644 backend/pkg/util/format.go create mode 100644 backend/pkg/util/format_test.go create mode 100644 backend/pkg/util/network.go create mode 100644 backend/pkg/util/network_test.go create mode 100644 backend/pkg/util/slice.go create mode 100644 backend/pkg/util/string.go create mode 100644 backend/pkg/util/value.go create mode 100644 backend/pkg/util/version.go create mode 100644 backend/pkg/util/version_compare.go diff --git a/backend/pkg/util/format.go b/backend/pkg/util/format.go new file mode 100644 index 00000000..5b18daba --- /dev/null +++ b/backend/pkg/util/format.go @@ -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 +} diff --git a/backend/pkg/util/format_test.go b/backend/pkg/util/format_test.go new file mode 100644 index 00000000..b2fd973f --- /dev/null +++ b/backend/pkg/util/format_test.go @@ -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) + } + } +} diff --git a/backend/pkg/util/network.go b/backend/pkg/util/network.go new file mode 100644 index 00000000..d7b864d5 --- /dev/null +++ b/backend/pkg/util/network.go @@ -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() +} diff --git a/backend/pkg/util/network_test.go b/backend/pkg/util/network_test.go new file mode 100644 index 00000000..62a9bd21 --- /dev/null +++ b/backend/pkg/util/network_test.go @@ -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) +} diff --git a/backend/pkg/util/slice.go b/backend/pkg/util/slice.go new file mode 100644 index 00000000..74eb7139 --- /dev/null +++ b/backend/pkg/util/slice.go @@ -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 +} diff --git a/backend/pkg/util/string.go b/backend/pkg/util/string.go new file mode 100644 index 00000000..9ea84e8d --- /dev/null +++ b/backend/pkg/util/string.go @@ -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) + } + } +} diff --git a/backend/pkg/util/value.go b/backend/pkg/util/value.go new file mode 100644 index 00000000..6473bd53 --- /dev/null +++ b/backend/pkg/util/value.go @@ -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" +} diff --git a/backend/pkg/util/version.go b/backend/pkg/util/version.go new file mode 100644 index 00000000..96118b06 --- /dev/null +++ b/backend/pkg/util/version.go @@ -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) +} diff --git a/backend/pkg/util/version_compare.go b/backend/pkg/util/version_compare.go new file mode 100644 index 00000000..c7e3d014 --- /dev/null +++ b/backend/pkg/util/version_compare.go @@ -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 +} From f3d85d51fb733c0a0e29f1ca542f93921fcf23de Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 01:00:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(pkg/cache/disk):=20LRU=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=B1=BB=E5=9E=8B=E6=96=AD=E8=A8=80=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E9=99=8D=E7=BA=A7=E8=80=8C=E9=9D=9E=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit items 与 evictList 的不变量一旦被破坏,读、写、删除与淘汰路径上的裸类型断言 会直接崩掉进程。改为带 ok 检查:Get 退化为缓存未命中,Set 报告污染条目, deleteUnlocked 跳过容量回退,evict 移除坏节点后继续。 新增 cache_corruption_test.go 锁住该行为:去掉守卫后用例会以 「interface conversion: interface {} is string, not *disk.cacheItem」失败, 加上守卫后 4 个用例全通过。 验证:go build 通过;go test ./pkg/cache/disk/ 全绿(含原有 5 个用例); golangci-lint 0 issues;check_cordis_architecture.sh 0 violations。 --- backend/pkg/cache/disk/cache.go | 35 ++++++++--- .../pkg/cache/disk/cache_corruption_test.go | 63 +++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 backend/pkg/cache/disk/cache_corruption_test.go diff --git a/backend/pkg/cache/disk/cache.go b/backend/pkg/cache/disk/cache.go index a4514957..83b32117 100644 --- a/backend/pkg/cache/disk/cache.go +++ b/backend/pkg/cache/disk/cache.go @@ -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) diff --git a/backend/pkg/cache/disk/cache_corruption_test.go b/backend/pkg/cache/disk/cache_corruption_test.go new file mode 100644 index 00000000..379c005a --- /dev/null +++ b/backend/pkg/cache/disk/cache_corruption_test.go @@ -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) + } + }) +}