List an AS's prefixes by mask length, shortest first (closes #153)
check / check (push) Waiting to run

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
This commit is contained in:
2026-10-27 03:29:21 +00:00
parent a5bff4e13c
commit b005222e96
3 changed files with 70 additions and 21 deletions
+12 -21
View File
@@ -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 {
+55
View File
@@ -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.