From b005222e9621e6e387fab9f4ab73d0627bfe3e02 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 03:29:21 +0000 Subject: [PATCH] List an AS's prefixes by mask length, shortest first (closes #153) The AS page sorted its IPv4 and IPv6 prefix lists by network address, then by mask length. It now sorts them by mask length, shortest first, and by network address within one mask length, so a /16 comes before a /24. The sort function is renamed from sortRoutesByNetwork to sortRoutesByMaskLength to say so. The handler of /api/v1/as/{asn} does not sort its lists, so it is unchanged. Model: opus-5-5 --- TODO.md | 3 ++ internal/server/handlers.go | 33 +++++--------- internal/server/handlers_internal_test.go | 55 +++++++++++++++++++++++ 3 files changed, 70 insertions(+), 21 deletions(-) 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.