List an AS's prefixes by mask length, shortest first (closes #153) #181

Merged
clawbot merged 1 commits from issue-153-prefix-length-order into next 2026-10-27 04:37:18 +01:00
3 changed files with 70 additions and 21 deletions
+3
View File
@@ -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
+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.