H3/H4/H5: zone-integrity invariants

H3+H4 — Zone SOA invariant. After parsing, loadRRs enforces:
exactly one SOA, owned by the zone apex. Catches three failure modes
with a single guard:

  - Missing SOA (H4): a malformed line earlier in the file may have
    tripped miekg/dns's ZoneParser into dropping records without
    reporting an error via parser.Err(). If the SOA went missing, we
    refuse rather than treat the partial parse as authoritative.

  - Multiple SOAs (H3): zone files with accidental duplicate SOA
    records produce inconsistent zone state visible to AXFR clients.
    The old code's first-match SOA-bump would silently propagate the
    inconsistency. Now we refuse.

  - Non-apex SOA (H3): an SOA whose owner doesn't match the zone
    origin is either a parse error or a hand-edit mistake; bumping
    it would leave the real apex unchanged. Now we refuse.

assertSingleApexSOA returns a descriptive error so the failure mode
is actionable from logs alone.

H5 — MaxUint32 guard in bumpSerial. The old "+1 defensive advance"
branch would wrap to 0 if soa.Serial == MaxUint32, and downstream
secondaries per RFC 1982 §3.2 treat 0-after-MaxUint32 as "older"
(they refuse to AXFR and the zone goes dark). Now we explicitly check
and refuse with a loud message; operator must reset the serial
manually. Practical reach is zero for our deployment (10000 bumps/day
× 117 years would still fit uint32) but the defensive ceiling matters
for fuzz, hand-edit, or future code-path errors.

The full RFC 1982 wraparound-aware comparison was prototyped but
removed: it broke the legacy-format migration case where a tiny
non-CalVer serial (e.g., 12345) is "more than 2^31 distant" from a
new-format serial (~2.6B), which RFC 1982 reads as "going backwards"
and would block migration. Naive `>` is correct in practice; the
MaxUint32 case is the only real failure mode worth guarding.

New tests:
- TestBumpSerial_MaxUint32_RefusesWrap
- TestLoadRRs_NoSOA_Refused
- TestLoadRRs_MultipleSOAs_Refused
- TestLoadRRs_NonApexSOA_Refused
This commit is contained in:
Ryan Malloy 2026-05-22 21:25:35 -06:00
parent 93ed180d8f
commit d9dad01798
2 changed files with 138 additions and 3 deletions

View file

@ -1,6 +1,7 @@
package rfc2136
import (
"math"
"os"
"path/filepath"
"strings"
@ -220,6 +221,80 @@ func TestBumpSerial_NonCalVerFormat_ResetsToToday(t *testing.T) {
}
}
// TestBumpSerial_MaxUint32_RefusesWrap covers H5: the defensive
// branch must not wrap soa.Serial to 0. Wrap-to-0 makes downstream
// secondaries treat the zone as reset per RFC 1982 and refuse to AXFR
// the new value, taking the zone dark.
func TestBumpSerial_MaxUint32_RefusesWrap(t *testing.T) {
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Serial: math.MaxUint32},
}
now := time.Date(2026, 5, 22, 1, 0, 0, 0, time.UTC)
if err := bumpSerial(rrs, now); err == nil {
t.Errorf("bumpSerial accepted MaxUint32; expected refusal to prevent wrap-to-0")
}
if got := rrs[0].(*dns.SOA).Serial; got == 0 {
t.Errorf("Serial wrapped to 0 (catastrophic): %d", got)
}
}
// TestLoadRRs_NoSOA_Refused covers H4: a zone file that's missing its
// SOA (because the parser ate it on a malformed prior line, or because
// it was never there) must fail loadRRs rather than be silently treated
// as a valid empty-of-SOA zone.
func TestLoadRRs_NoSOA_Refused(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "noSoA.example.com.zone")
content := "$ORIGIN noSoA.example.com.\nfoo.noSoA.example.com. 60 IN A 192.0.2.1\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write fixture: %v", err)
}
zf := openZoneFile(path, "noSoA.example.com.")
if _, _, err := zf.loadRRs(); err == nil {
t.Errorf("loadRRs accepted SOA-less zone file; expected refusal")
}
}
// TestLoadRRs_MultipleSOAs_Refused covers H3: two SOAs at the apex
// produce inconsistent zone state visible to AXFR clients. We refuse
// to operate on such a file rather than bumping only the first SOA.
func TestLoadRRs_MultipleSOAs_Refused(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dupe.example.com.zone")
content := `$ORIGIN dupe.example.com.
dupe.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60
dupe.example.com. 3600 IN SOA ns.example. admin.example. 2 60 60 60 60
dupe.example.com. 3600 IN NS ns.example.
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write fixture: %v", err)
}
zf := openZoneFile(path, "dupe.example.com.")
if _, _, err := zf.loadRRs(); err == nil {
t.Errorf("loadRRs accepted dual-SOA zone; expected refusal")
}
}
// TestLoadRRs_NonApexSOA_Refused covers H3: an SOA owned by a name
// other than the zone apex is a parse-error red flag and must be
// refused, not silently bumped.
func TestLoadRRs_NonApexSOA_Refused(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "weird.example.com.zone")
content := `$ORIGIN weird.example.com.
sub.weird.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60
weird.example.com. 3600 IN NS ns.example.
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write fixture: %v", err)
}
zf := openZoneFile(path, "weird.example.com.")
if _, _, err := zf.loadRRs(); err == nil {
t.Errorf("loadRRs accepted non-apex SOA; expected refusal")
}
}
func TestBumpSerial_NoSOA_ReturnsError(t *testing.T) {
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)}
now := time.Now()