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 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-20 08:49:58 +01:00
1 parent fa8feb6bd9
commit d18fe18ccd
8 files changed
+96 -15

No files matched your search

+8
View File
@@ -42,6 +42,14 @@ All notable changes to Jarl are documented here. The format follows
### Changed ### 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 - `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 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 once). A chain of three or more CNAMEs gets an info note that shows
+1 -1
View File
@@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule {
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j] a, b := out[i], out[j]
if a.Name != b.Name { if a.Name != b.Name {
return CanonicalLess(a.Name+".", b.Name+".") return NameLess(a.Name+".", b.Name+".")
} }
if a.Exact != b.Exact { if a.Exact != b.Exact {
return !a.Exact return !a.Exact
+41 -10
View File
@@ -524,14 +524,14 @@ func (s *State) SortedZones() []*Zone {
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj { if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
return pi < pj return pi < pj
} }
return CanonicalLess(out[i].Name, out[j].Name) return NameLess(out[i].Name, out[j].Name)
}) })
return out return out
} }
// SortZones sorts zones by name in DNS canonical order. // SortZones sorts zones by name (see NameLess).
func SortZones(zs []*Zone) { 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). // 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 // 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. // data.
func SortRecords(rs []*Record, z *Zone) { func SortRecords(rs []*Record, z *Zone) {
apex := "" apex := ""
@@ -662,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) {
if b.Name == apex { if b.Name == apex {
return false return false
} }
return CanonicalLess(a.Name, b.Name) return NameLess(a.Name, b.Name)
} }
if a.Type != b.Type { if a.Type != b.Type {
return typeOrder(a.Type) < typeOrder(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 // NameLess compares names label by label from the right, like DNS
// 6.1): by label from the right. // canonical order (RFC 4034 section 6.1), except that two all-digit
func CanonicalLess(a, b string) bool { // 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, "."), ".") la := strings.Split(strings.TrimSuffix(a, "."), ".")
lb := strings.Split(strings.TrimSuffix(b, "."), ".") 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 { for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
if la[i] != lb[j] { if c := compareLabel(la[i], lb[j]); c != 0 {
return la[i] < lb[j] return c < 0
} }
} }
return len(la) < len(lb) 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. // RRSetKey identifies an RRset.
type RRSetKey struct { type RRSetKey struct {
Name string Name string
+42
View File
@@ -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")
}
}
+1 -1
View File
@@ -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 { sort.Slice(recs, func(i, j int) bool {
if recs[i].Name != recs[j].Name { 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 return recs[i].ID < recs[j].ID
}) })
+1 -1
View File
@@ -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 { slices.SortFunc(recs, func(a, b *model.Record) int {
if a.Name != b.Name { if a.Name != b.Name {
if model.CanonicalLess(a.Name, b.Name) { if model.NameLess(a.Name, b.Name) {
return -1 return -1
} }
return 1 return 1
+1 -1
View File
@@ -186,7 +186,7 @@ func (c *checker) checkZoneRecords(z *model.Zone) {
for n := range ix.types { for n := range ix.types {
names = append(names, n) 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 { for _, name := range names {
types := ix.types[name] types := ix.types[name]
+1 -1
View File
@@ -189,7 +189,7 @@ func sortProblems(ps []Problem) {
return a.Zone < b.Zone return a.Zone < b.Zone
} }
if a.Name != b.Name { if a.Name != b.Name {
return model.CanonicalLess(a.Name, b.Name) return model.NameLess(a.Name, b.Name)
} }
return a.Message < b.Message return a.Message < b.Message
}) })