diff --git a/TODO.md b/TODO.md index 5ed9538..e80be3a 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,9 @@ tested. # Completed Steps +- 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length, + shortest first, and prefixes of one mask length by address. They used to be + listed by address (closes #153) - 2026-10-27: on `/` and `/status`, a spinner beside the status turns one frame each time the stats show more messages than the time before. A page that has not seen more messages for three refreshes shows "stale" and the time since diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 156a47a..7c20bc1 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,7 +1,7 @@ package server import ( - "bytes" + "cmp" "context" "encoding/json" "errors" @@ -9,6 +9,7 @@ import ( "net/http" "net/netip" "runtime" + "slices" "sort" "strconv" "strings" @@ -1028,24 +1029,15 @@ func splitRoutesByIPVersion( return ipv4Routes, ipv6Routes } -// sortRoutesByNetwork sorts routes by network address, then by mask length. -func sortRoutesByNetwork(routes []database.LiveRoute) { - sort.Slice(routes, func(i, j int) bool { - // Parse the prefixes to compare network addresses - ipI, netI, _ := net.ParseCIDR(routes[i].Prefix) - ipJ, netJ, _ := net.ParseCIDR(routes[j].Prefix) +// sortRoutesByMaskLength sorts routes by mask length, shortest first, and +// routes with the same mask length by network address. +func sortRoutesByMaskLength(routes []database.LiveRoute) { + slices.SortFunc(routes, func(a, b database.LiveRoute) int { + prefixA, _ := netip.ParsePrefix(a.Prefix) + prefixB, _ := netip.ParsePrefix(b.Prefix) - // Compare by network address first - cmp := bytes.Compare(ipI.To16(), ipJ.To16()) - if cmp != 0 { - return cmp < 0 - } - - // If network addresses are equal, compare by mask length - onesI, _ := netI.Mask.Size() - onesJ, _ := netJ.Mask.Size() - - return onesI < onesJ + return cmp.Or(cmp.Compare(prefixA.Bits(), prefixB.Bits()), + prefixA.Addr().Compare(prefixB.Addr())) }) } @@ -1212,9 +1204,8 @@ func (s *Server) handleASDetail() http.HandlerFunc { // Group prefixes by IP version ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes) - // Sort prefixes by network address - sortRoutesByNetwork(ipv4Prefixes) - sortRoutesByNetwork(ipv6Prefixes) + sortRoutesByMaskLength(ipv4Prefixes) + sortRoutesByMaskLength(ipv6Prefixes) // Prepare template data data := struct { diff --git a/internal/server/handlers_internal_test.go b/internal/server/handlers_internal_test.go index 620276a..78a8f83 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -1586,6 +1586,61 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) { } } +// TestSortRoutesByMaskLength expects the AS page's prefix lists in mask length +// order, shortest first, and in address order within one mask length. Before +// the fix they were in address order, so 10.1.0.0/16 came before 11.0.0.0/8 +// (https://git.eeqj.de/sneak/routewatch/issues/153). +func TestSortRoutesByMaskLength(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefixes []string + want []string + }{ + { + name: "IPv4", + prefixes: []string{ + "100.64.1.0/24", "11.0.0.0/8", "10.1.0.0/16", + "20.0.1.0/24", "172.16.0.0/12", "9.0.0.0/8", + }, + want: []string{ + "9.0.0.0/8", "11.0.0.0/8", "172.16.0.0/12", + "10.1.0.0/16", "20.0.1.0/24", "100.64.1.0/24", + }, + }, + { + name: "IPv6", + prefixes: []string{ + "2001:db8:1::/48", "2001:db8:8000::/33", "2001:db8:0:1::/64", + "2001:db8::/48", "2001:db8::/33", + }, + want: []string{ + "2001:db8::/33", "2001:db8:8000::/33", "2001:db8::/48", + "2001:db8:1::/48", "2001:db8:0:1::/64", + }, + }, + } + + for _, tt := range tests { + routes := make([]database.LiveRoute, 0, len(tt.prefixes)) + for _, prefix := range tt.prefixes { + routes = append(routes, database.LiveRoute{Prefix: prefix}) + } + + sortRoutesByMaskLength(routes) + + got := make([]string, 0, len(routes)) + for _, route := range routes { + got = append(got, route.Prefix) + } + + if !slices.Equal(got, tt.want) { + t.Errorf("%s: order %v, want %v", tt.name, got, tt.want) + } + } +} + // TestNoOriginHasNoASAnswers stores a route with no known origin, which has // origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects // /as/0 and /api/v1/as/0 to answer 404.