From d18fe18ccd40bc041bf512612faebb124a7fc31e Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 20 Oct 2026 08:49:58 +0100 Subject: [PATCH] Sort numeric labels by value Records, zones and findings now order all-digit labels numerically, so reverse zones list 2 before 101. model.CanonicalLess is replaced by model.NameLess; other labels keep byte order. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++++ internal/model/blocking.go | 2 +- internal/model/model.go | 51 +++++++++++++++++++++++++------- internal/model/nameorder_test.go | 42 ++++++++++++++++++++++++++ internal/server/h_config.go | 2 +- internal/server/h_stats.go | 2 +- internal/validate/records.go | 2 +- internal/validate/validate.go | 2 +- 8 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 internal/model/nameorder_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cc3e086..076a9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,14 @@ All notable changes to Jarl are documented here. The format follows ### Changed +- Records, zones and validation findings sort numeric labels by value, so + `2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the + web UI, the CLI and the rendered zone files. Names still group by label + from the right (subdomains stay under their parent); other labels keep + the old byte order (`host10` before `host2`). Zone files are rewritten + in the new order the next time they change; named doesn't care about + record order. + - `cname_chain` no longer warns about one CNAME pointing at another (a service name at a host alias is common, and named answers the chain at once). A chain of three or more CNAMEs gets an info note that shows diff --git a/internal/model/blocking.go b/internal/model/blocking.go index 23463bc..6fe8c8e 100644 --- a/internal/model/blocking.go +++ b/internal/model/blocking.go @@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule { sort.Slice(out, func(i, j int) bool { a, b := out[i], out[j] if a.Name != b.Name { - return CanonicalLess(a.Name+".", b.Name+".") + return NameLess(a.Name+".", b.Name+".") } if a.Exact != b.Exact { return !a.Exact diff --git a/internal/model/model.go b/internal/model/model.go index 54b7acb..3f7a2ca 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -524,14 +524,14 @@ func (s *State) SortedZones() []*Zone { if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj { return pi < pj } - return CanonicalLess(out[i].Name, out[j].Name) + return NameLess(out[i].Name, out[j].Name) }) return out } -// SortZones sorts zones by name in DNS canonical order. +// SortZones sorts zones by name (see NameLess). func SortZones(zs []*Zone) { - sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) }) + sort.Slice(zs, func(i, j int) bool { return NameLess(zs[i].Name, zs[j].Name) }) } // FindZone finds a zone by name within a view (viewID 0 for no view). @@ -646,7 +646,7 @@ func typeOrder(t string) string { } // SortRecords sorts records in zone-file order: apex first, then names in -// DNS canonical order (so subdomains group together), then by type and +// NameLess order (so subdomains group together), then by type and // data. func SortRecords(rs []*Record, z *Zone) { apex := "" @@ -662,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) { if b.Name == apex { return false } - return CanonicalLess(a.Name, b.Name) + return NameLess(a.Name, b.Name) } if a.Type != b.Type { return typeOrder(a.Type) < typeOrder(b.Type) @@ -674,19 +674,50 @@ func SortRecords(rs []*Record, z *Zone) { }) } -// CanonicalLess compares names in DNS canonical order (RFC 4034 section -// 6.1): by label from the right. -func CanonicalLess(a, b string) bool { +// NameLess compares names label by label from the right, like DNS +// canonical order (RFC 4034 section 6.1), except that two all-digit +// labels compare as numbers, so 2.10.0.10.in-addr.arpa comes before +// 101.10.0.10.in-addr.arpa. It orders names for people and zone files; +// BIND does its own canonical ordering where DNSSEC needs it. +func NameLess(a, b string) bool { la := strings.Split(strings.TrimSuffix(a, "."), ".") lb := strings.Split(strings.TrimSuffix(b, "."), ".") for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 { - if la[i] != lb[j] { - return la[i] < lb[j] + if c := compareLabel(la[i], lb[j]); c != 0 { + return c < 0 } } return len(la) < len(lb) } +func compareLabel(a, b string) int { + if a == b { + return 0 + } + if allDigits(a) && allDigits(b) { + na, nb := strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0") + if len(na) != len(nb) { + return len(na) - len(nb) + } + if c := strings.Compare(na, nb); c != 0 { + return c + } + } + return strings.Compare(a, b) +} + +func allDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + // RRSetKey identifies an RRset. type RRSetKey struct { Name string diff --git a/internal/model/nameorder_test.go b/internal/model/nameorder_test.go new file mode 100644 index 0000000..41f496a --- /dev/null +++ b/internal/model/nameorder_test.go @@ -0,0 +1,42 @@ +package model + +import ( + "slices" + "testing" +) + +func TestNameLess(t *testing.T) { + want := []string{ + "10.0.10.in-addr.arpa.", + "1.10.0.10.in-addr.arpa.", + "02.10.0.10.in-addr.arpa.", + "2.10.0.10.in-addr.arpa.", + "10.10.0.10.in-addr.arpa.", + "101.10.0.10.in-addr.arpa.", + "x.101.10.0.10.in-addr.arpa.", + "254.10.0.10.in-addr.arpa.", + "a.10.0.10.in-addr.arpa.", + } + got := slices.Clone(want) + slices.Reverse(got) + slices.SortFunc(got, func(a, b string) int { + if NameLess(a, b) { + return -1 + } + if NameLess(b, a) { + return 1 + } + return 0 + }) + if !slices.Equal(got, want) { + t.Fatalf("got %v\nwant %v", got, want) + } + + // Non-numeric labels keep canonical (byte) order. + if !NameLess("host10.example.", "host2.example.") { + t.Error("host10 should sort before host2") + } + if !NameLess("example.", "www.example.") { + t.Error("parent should sort before child") + } +} diff --git a/internal/server/h_config.go b/internal/server/h_config.go index 30ce340..5f249bc 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -637,7 +637,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa } sort.Slice(recs, func(i, j int) bool { if recs[i].Name != recs[j].Name { - return model.CanonicalLess(recs[i].Name, recs[j].Name) + return model.NameLess(recs[i].Name, recs[j].Name) } return recs[i].ID < recs[j].ID }) diff --git a/internal/server/h_stats.go b/internal/server/h_stats.go index 44afc80..9b31850 100644 --- a/internal/server/h_stats.go +++ b/internal/server/h_stats.go @@ -215,7 +215,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model } slices.SortFunc(recs, func(a, b *model.Record) int { if a.Name != b.Name { - if model.CanonicalLess(a.Name, b.Name) { + if model.NameLess(a.Name, b.Name) { return -1 } return 1 diff --git a/internal/validate/records.go b/internal/validate/records.go index 66fd9a1..f48ab98 100644 --- a/internal/validate/records.go +++ b/internal/validate/records.go @@ -186,7 +186,7 @@ func (c *checker) checkZoneRecords(z *model.Zone) { for n := range ix.types { names = append(names, n) } - sort.Slice(names, func(i, j int) bool { return model.CanonicalLess(names[i], names[j]) }) + sort.Slice(names, func(i, j int) bool { return model.NameLess(names[i], names[j]) }) for _, name := range names { types := ix.types[name] diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 1826b84..f81a1d0 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -189,7 +189,7 @@ func sortProblems(ps []Problem) { return a.Zone < b.Zone } if a.Name != b.Name { - return model.CanonicalLess(a.Name, b.Name) + return model.NameLess(a.Name, b.Name) } return a.Message < b.Message })