A domain name in the RDATA section written without a trailing dot is relative to the zone origin (RFC 1035 §5.1), so "www CNAME host" in example.com. means host.example.com. The qualifier ran on the record after dns.NewRR had already resolved every RDATA name against the root, which turns "host" into "host." and makes the two indistinguishable -- relative() saw the trailing dot and left it alone. Every relative CNAME, NS, MX, PTR, SRV, NAPTR, SOA and SVCB target therefore pointed at the DNS root, which showed up as a local zone answering a CNAME with "host." and the alias target holding no local answer. Qualification now happens in assemble on the token stream, before the record is built, using a table of the RDATA positions that hold a name per type; names that already end in a dot stay absolute, so the RPZ targets "." and "rpz-drop." and the null MX targets are unaffected. Fix three wrong expectations in the localzone tests while here. The loop fixture was built with dns.NewRR on "alias.example.com", which cannot parse because the RR library wants a TTL and class column, so the expected record now spells them out. The origin-derivation fixture used "a" as an owner name, and a leading token that names an RR type reads as an omitted owner, which has no previous owner to inherit on the first line of a file; the owner is now a word. The merge test counted the SOA, but it is zone metadata rather than a queryable record, so two files contribute three records. The holder race test also looked up a name from the base zone while the holder was serving a store compiled from other.test., which no answer could satisfy; it now accepts either store's data.
975 lines
31 KiB
Go
975 lines
31 KiB
Go
package zonefile
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
func mustParse(t *testing.T, src string, opt Options) *Zone {
|
|
t.Helper()
|
|
z, err := Parse(strings.NewReader(src), opt)
|
|
if err != nil {
|
|
t.Fatalf("Parse: unexpected error: %v\nsource:\n%s", err, src)
|
|
}
|
|
return z
|
|
}
|
|
|
|
func parseErr(t *testing.T, src string, opt Options) error {
|
|
t.Helper()
|
|
z, err := Parse(strings.NewReader(src), opt)
|
|
if err == nil {
|
|
t.Fatalf("Parse: expected an error, got %d records: %v", len(z.Records), z.Records)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// summary renders every record as "line name ttl class type rdata" so a single
|
|
// comparison covers folding, owner/TTL/class inheritance and origin resolution.
|
|
func summary(t *testing.T, z *Zone) []string {
|
|
t.Helper()
|
|
out := make([]string, 0, len(z.Records))
|
|
for _, rec := range z.Records {
|
|
out = append(out, renderRecord(t, rec, true))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// summaryNoTTL is summary without the TTL column, for fixtures whose records
|
|
// carry no explicit TTL (the TTL then comes from the RR header).
|
|
func summaryNoTTL(t *testing.T, z *Zone) []string {
|
|
t.Helper()
|
|
out := make([]string, 0, len(z.Records))
|
|
for _, rec := range z.Records {
|
|
out = append(out, renderRecord(t, rec, false))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func renderRecord(t *testing.T, rec Record, withTTL bool) string {
|
|
t.Helper()
|
|
parts := strings.SplitN(rec.RR.String(), "\t", 5)
|
|
if len(parts) != 5 {
|
|
t.Fatalf("record line %d does not render as 5 tab-separated fields: %q", rec.Line, rec.Text())
|
|
}
|
|
if !withTTL {
|
|
return fmt.Sprintf("%d %s %s %s %s", rec.Line, parts[0], parts[2], parts[3], parts[4])
|
|
}
|
|
return fmt.Sprintf("%d %s %d %s %s %s", rec.Line, parts[0], rec.TTL, parts[2], parts[3], parts[4])
|
|
}
|
|
|
|
func checkSummary(t *testing.T, z *Zone, want []string) {
|
|
t.Helper()
|
|
if got := summary(t, z); !slices.Equal(got, want) {
|
|
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func recordByName(t *testing.T, z *Zone, name string) Record {
|
|
t.Helper()
|
|
for _, rec := range z.Records {
|
|
if rec.Name == name {
|
|
return rec
|
|
}
|
|
}
|
|
t.Fatalf("no record for %q in %v", name, summary(t, z))
|
|
return Record{}
|
|
}
|
|
|
|
func recordByLine(t *testing.T, z *Zone, line int) Record {
|
|
t.Helper()
|
|
for _, rec := range z.Records {
|
|
if rec.Line == line {
|
|
return rec
|
|
}
|
|
}
|
|
t.Fatalf("no record starting on line %d in %v", line, summary(t, z))
|
|
return Record{}
|
|
}
|
|
|
|
func TestParseTTL(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
want uint32
|
|
wantErr bool
|
|
}{
|
|
{in: "", wantErr: true},
|
|
{in: " ", wantErr: true},
|
|
{in: "300", want: 300},
|
|
{in: " 60 ", want: 60},
|
|
{in: "0", want: 0},
|
|
{in: "300s", want: 300},
|
|
{in: "300sec", want: 300},
|
|
{in: "300seconds", want: 300},
|
|
{in: "5m", want: 300},
|
|
{in: "5min", want: 300},
|
|
{in: "5minutes", want: 300},
|
|
{in: "1h", want: 3600},
|
|
{in: "1hour", want: 3600},
|
|
{in: "1hours", want: 3600},
|
|
{in: "1d", want: 86400},
|
|
{in: "1days", want: 86400},
|
|
{in: "1w", want: 604800},
|
|
{in: "1weeks", want: 604800},
|
|
{in: "1h30m", want: 5400},
|
|
{in: "1w2d3h4m5s", want: 788645},
|
|
{in: "1h30", want: 3630},
|
|
{in: "4294967295", want: 4294967295},
|
|
{in: "4294967296", wantErr: true},
|
|
{in: "-1", wantErr: true},
|
|
{in: "-5m", wantErr: true},
|
|
{in: "abc", wantErr: true},
|
|
{in: "m", wantErr: true},
|
|
{in: "1x", wantErr: true},
|
|
{in: "1.5h", wantErr: true},
|
|
{in: "1h-", wantErr: true},
|
|
{in: "0x10", wantErr: true},
|
|
}
|
|
for _, tc := range tests {
|
|
got, err := ParseTTL(tc.in)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Errorf("ParseTTL(%q) = %d, want an error", tc.in, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("ParseTTL(%q) error: %v", tc.in, err)
|
|
continue
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("ParseTTL(%q) = %d, want %d", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFormatTTL(t *testing.T) {
|
|
tests := []struct {
|
|
in uint32
|
|
want string
|
|
}{
|
|
{0, "0"},
|
|
{1, "1"},
|
|
{59, "59"},
|
|
{60, "1m"},
|
|
{300, "5m"},
|
|
{3600, "1h"},
|
|
{86400, "1d"},
|
|
{604800, "1w"},
|
|
{3601, "3601"},
|
|
{1209600, "2w"},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := FormatTTL(tc.in); got != tc.want {
|
|
t.Errorf("FormatTTL(%d) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFormatTTLRoundTrip(t *testing.T) {
|
|
for _, ttl := range []uint32{0, 1, 59, 60, 300, 3600, 3601, 86400, 604800, 1209600, 4294967295} {
|
|
s := FormatTTL(ttl)
|
|
got, err := ParseTTL(s)
|
|
if err != nil {
|
|
t.Errorf("ParseTTL(FormatTTL(%d)=%q) error: %v", ttl, s, err)
|
|
continue
|
|
}
|
|
if got != ttl {
|
|
t.Errorf("round trip of %d via %q = %d", ttl, s, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSerialNext(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
current uint32
|
|
spec string
|
|
want uint32
|
|
wantErr bool
|
|
}{
|
|
{name: "increment", current: 1, spec: "+1", want: 2},
|
|
{name: "increment large", current: 2024010101, spec: "+100", want: 2024010201},
|
|
{name: "increment from zero", current: 0, spec: "+7", want: 7},
|
|
{name: "decrement", current: 10, spec: "-1", want: 9},
|
|
{name: "decrement below zero wraps", current: 0, spec: "-1", want: 4294967295},
|
|
{name: "absolute", current: 5, spec: "42", want: 42},
|
|
{name: "absolute max", current: 5, spec: "4294967295", want: 4294967295},
|
|
{name: "surrounding space", current: 1, spec: " +1 ", want: 2},
|
|
{name: "empty", current: 1, spec: "", wantErr: true},
|
|
{name: "only plus", current: 1, spec: "+", wantErr: true},
|
|
{name: "not a number", current: 1, spec: "+x", wantErr: true},
|
|
{name: "word", current: 1, spec: "next", wantErr: true},
|
|
{name: "bare minus", current: 1, spec: "-", wantErr: true},
|
|
{name: "overflow", current: 1, spec: "+4294967296", wantErr: true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := SerialNext(tc.current, tc.spec)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("SerialNext(%d, %q) = %d, want an error", tc.current, tc.spec, got)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("SerialNext(%d, %q): %v", tc.current, tc.spec, err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("SerialNext(%d, %q) = %d, want %d", tc.current, tc.spec, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// addSerial is plain modular (RFC 1982 §2.1) arithmetic: 0xFFFFFFFF + 1 is 0,
|
|
// not 1. The high-half special case only stops two large operands from being
|
|
// mistaken for a wrap.
|
|
func TestAddSerial(t *testing.T) {
|
|
tests := []struct{ a, b, want uint32 }{
|
|
{1, 1, 2},
|
|
{0, 0, 0},
|
|
{4294967295, 1, 0},
|
|
{4294967295, 2, 1},
|
|
{4294967294, 3, 1},
|
|
{0, 4294967295, 4294967295},
|
|
{0x7FFFFFFF, 1, 0x80000000},
|
|
{0x80000000, 0x80000000, 0},
|
|
{0x80000000, 0x7FFFFFFF, 0xFFFFFFFF},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := addSerial(tc.a, tc.b); got != tc.want {
|
|
t.Errorf("addSerial(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
const fullZone = `$ORIGIN example.com.
|
|
$TTL 1h
|
|
@ 300 IN SOA ns1.example.com. hostmaster.example.com. (
|
|
2024010101 ; serial
|
|
7200 ; refresh
|
|
3600 ; retry
|
|
1209600 ; expire
|
|
300 ) ; minimum
|
|
@ IN NS ns1.example.com.
|
|
www 3600 IN A 192.0.2.1
|
|
www 600 IN A 192.0.2.2
|
|
AAAA 2001:db8::1
|
|
alias 3600 IN CNAME www.example.com.
|
|
mail 3600 IN MX 10 mx1.example.com.
|
|
note 3600 IN TXT "hello ; world @ example"
|
|
*.wild 3600 IN A 192.0.2.3
|
|
198.51.100.5. IN A 198.51.100.5
|
|
low in a 192.0.2.7
|
|
mixed 600 IN A 192.0.2.8
|
|
cls in 3600 A 192.0.2.9
|
|
at 3600 IN TXT "@"
|
|
|
|
; a whole-line comment
|
|
; an indented comment
|
|
|
|
`
|
|
|
|
func TestParseFullZone(t *testing.T) {
|
|
z := mustParse(t, fullZone, Options{File: "full.zone", Origin: "ignored.example."})
|
|
if z.Origin != "example.com." {
|
|
t.Errorf("Origin = %q, want %q", z.Origin, "example.com.")
|
|
}
|
|
if z.File != "full.zone" {
|
|
t.Errorf("File = %q, want %q", z.File, "full.zone")
|
|
}
|
|
// The SOA and NS lines are folded, so every record reports the line its
|
|
// statement started on. TTLs are left out here: only some records carry an
|
|
// explicit one, see TestParseTTLDirective for the $TTL handling.
|
|
if got, want := summaryNoTTL(t, z), []string{
|
|
`3 example.com. IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300`,
|
|
`9 example.com. IN NS ns1.example.com.`,
|
|
`10 www.example.com. IN A 192.0.2.1`,
|
|
`11 www.example.com. IN A 192.0.2.2`,
|
|
`12 www.example.com. IN AAAA 2001:db8::1`,
|
|
`13 alias.example.com. IN CNAME www.example.com.`,
|
|
`14 mail.example.com. IN MX 10 mx1.example.com.`,
|
|
`15 note.example.com. IN TXT "hello ; world @ example"`,
|
|
`16 *.wild.example.com. IN A 192.0.2.3`,
|
|
`17 198.51.100.5. IN A 198.51.100.5`,
|
|
`18 low.example.com. IN A 192.0.2.7`,
|
|
`19 mixed.example.com. IN A 192.0.2.8`,
|
|
`20 cls.example.com. IN A 192.0.2.9`,
|
|
`21 at.example.com. IN TXT "@"`,
|
|
}; !slices.Equal(got, want) {
|
|
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
|
|
}
|
|
if z.SOA == nil {
|
|
t.Fatal("SOA = nil, want the parsed start of authority")
|
|
}
|
|
if z.SOA.Serial != 2024010101 {
|
|
t.Errorf("SOA.Serial = %d, want 2024010101", z.SOA.Serial)
|
|
}
|
|
if got := recordByLine(t, z, 11).TTL; got != 600 {
|
|
t.Errorf("explicit TTL = %d, want 600", got)
|
|
}
|
|
if got := recordByLine(t, z, 10).RR.Header().Ttl; got != 3600 {
|
|
t.Errorf("record TTL of the first www = %d, want the explicit 3600", got)
|
|
}
|
|
if got := recordByLine(t, z, 20).TTL; got != 3600 {
|
|
t.Errorf("TTL before the class = %d, want 3600", got)
|
|
}
|
|
if z.MinTTL != 300 {
|
|
t.Errorf("MinTTL = %d, want 300 (the smallest explicit TTL)", z.MinTTL)
|
|
}
|
|
for _, rec := range z.Records {
|
|
if rec.RR.Header().Class != dns.ClassINET {
|
|
t.Errorf("record %q has class %s, want IN", rec.Text(), dns.ClassToString[rec.RR.Header().Class])
|
|
}
|
|
if rec.Name != strings.ToLower(rec.Name) {
|
|
t.Errorf("Record.Name = %q, want lowercase", rec.Name)
|
|
}
|
|
if !strings.HasSuffix(rec.Name, ".") {
|
|
t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name)
|
|
}
|
|
if rec.File != "full.zone" {
|
|
t.Errorf("record %q has File %q, want full.zone", rec.Text(), rec.File)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseTTLDirective(t *testing.T) {
|
|
z := mustParse(t, `$ORIGIN example.com.
|
|
$TTL 1h
|
|
one 3600 IN A 192.0.2.1
|
|
two 300 IN A 192.0.2.2
|
|
three 300 IN A 192.0.2.3
|
|
$TTL 2h
|
|
four 7200 IN A 192.0.2.4
|
|
`, Options{})
|
|
checkSummary(t, z, []string{
|
|
`3 one.example.com. 3600 IN A 192.0.2.1`,
|
|
`4 two.example.com. 300 IN A 192.0.2.2`,
|
|
`5 three.example.com. 300 IN A 192.0.2.3`,
|
|
`7 four.example.com. 7200 IN A 192.0.2.4`,
|
|
})
|
|
if z.DefaultTTL != 7200 {
|
|
t.Errorf("DefaultTTL = %d, want 7200 after \"$TTL 2h\"", z.DefaultTTL)
|
|
}
|
|
if z.MinTTL != 300 {
|
|
t.Errorf("MinTTL = %d, want 300", z.MinTTL)
|
|
}
|
|
}
|
|
|
|
func TestParseDefaultTTLSources(t *testing.T) {
|
|
t.Run("option default ttl", func(t *testing.T) {
|
|
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com.", DefaultTTL: 42})
|
|
if z.DefaultTTL != 42 {
|
|
t.Errorf("DefaultTTL = %d, want 42", z.DefaultTTL)
|
|
}
|
|
if len(z.Records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(z.Records))
|
|
}
|
|
})
|
|
|
|
t.Run("no ttl source at all", func(t *testing.T) {
|
|
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
|
if z.DefaultTTL != 0 {
|
|
t.Errorf("DefaultTTL = %d, want 0", z.DefaultTTL)
|
|
}
|
|
// The RR header already carries the library default, so MinTTL is
|
|
// that value and the parser's "no TTL at all" fallback never runs.
|
|
if z.MinTTL != z.Records[0].RR.Header().Ttl {
|
|
t.Errorf("MinTTL = %d, want the record TTL %d", z.MinTTL, z.Records[0].RR.Header().Ttl)
|
|
}
|
|
})
|
|
|
|
t.Run("soa ttl becomes the default", func(t *testing.T) {
|
|
z := mustParse(t, "@ 300 IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5\nhost IN A 192.0.2.1\n",
|
|
Options{Origin: "example.com."})
|
|
if z.DefaultTTL != 300 {
|
|
t.Errorf("DefaultTTL = %d, want 300 from the SOA", z.DefaultTTL)
|
|
}
|
|
})
|
|
|
|
t.Run("soa minimum is the last resort", func(t *testing.T) {
|
|
z := mustParse(t, "@ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 7\nhost IN A 192.0.2.1\n",
|
|
Options{Origin: "example.com."})
|
|
// The SOA carries no explicit TTL, so its header TTL wins over Minttl.
|
|
if z.SOA == nil || z.SOA.Minttl != 7 {
|
|
t.Fatalf("SOA = %v, want Minttl 7", z.SOA)
|
|
}
|
|
})
|
|
|
|
t.Run("a file without ttl, soa or option keeps the header ttl", func(t *testing.T) {
|
|
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
|
if got := z.Records[0].RR.Header().Ttl; got == 0 {
|
|
t.Errorf("record header TTL = 0, want the parser default")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseOwnerForms(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
src string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "at sign is the origin",
|
|
src: "@ IN A 192.0.2.1\n@ IN A 192.0.2.2\n",
|
|
want: []string{"origin.example. 192.0.2.1", "origin.example. 192.0.2.2"},
|
|
},
|
|
{
|
|
name: "at sign follows $ORIGIN",
|
|
src: "$ORIGIN sub.example.com.\n@ IN A 192.0.2.1\n",
|
|
want: []string{"sub.example.com. 192.0.2.1"},
|
|
},
|
|
{
|
|
// An omitted owner is recognised by the line starting with a type
|
|
// token; a leading class or TTL is taken as an owner name instead.
|
|
name: "omitted owner repeats the previous one",
|
|
src: "one IN A 192.0.2.1\n A 192.0.2.2\n AAAA 2001:db8::1\n A 192.0.2.3\n",
|
|
want: []string{
|
|
"one.origin.example. 192.0.2.1",
|
|
"one.origin.example. 192.0.2.2",
|
|
"one.origin.example. 2001:db8::1",
|
|
"one.origin.example. 192.0.2.3",
|
|
},
|
|
},
|
|
{
|
|
name: "omitted owner after a parenthesised record",
|
|
src: "one IN TXT ( \"one\" ; comment\n \"two\" )\n A 192.0.2.1\n",
|
|
want: []string{"one.origin.example. \"one\" \"two\"", "one.origin.example. 192.0.2.1"},
|
|
},
|
|
{
|
|
name: "omitted owner is the origin after a directive",
|
|
src: "$ORIGIN example.com.\nA 192.0.2.1\n",
|
|
want: []string{"example.com. 192.0.2.1"},
|
|
},
|
|
{
|
|
name: "wildcard owner keeps the star",
|
|
src: "*.sub IN A 192.0.2.1\n",
|
|
want: []string{"*.sub.origin.example. 192.0.2.1"},
|
|
},
|
|
{
|
|
name: "at sign in a value is not expanded",
|
|
src: "note IN TXT \"user @ example.com\"\n",
|
|
want: []string{"note.origin.example. \"user @ example.com\""},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
z := mustParse(t, tc.src, Options{Origin: "origin.example."})
|
|
got := make([]string, 0, len(z.Records))
|
|
for _, rec := range z.Records {
|
|
parts := strings.SplitN(rec.RR.String(), "\t", 5)
|
|
got = append(got, parts[0]+" "+parts[4])
|
|
}
|
|
if !slices.Equal(got, tc.want) {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
// Record.Name is the resolved, lowercased, fully qualified owner:
|
|
// "@" always resolves to the origin, never to a literal "@" label.
|
|
for _, rec := range z.Records {
|
|
if strings.ContainsAny(rec.Name, "@") {
|
|
t.Errorf("Record.Name = %q, want an expanded owner", rec.Name)
|
|
}
|
|
if !strings.HasSuffix(rec.Name, ".") {
|
|
t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name)
|
|
}
|
|
if rec.Name != strings.ToLower(rec.Name) {
|
|
t.Errorf("Record.Name = %q, want lowercase", rec.Name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseOwnerNameCaseIsPreservedInTheRecord(t *testing.T) {
|
|
// The RR keeps the case the file used; only Record.Name is normalised.
|
|
z := mustParse(t, "WWW IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
|
if got := z.Records[0].Name; got != "www.example.com." {
|
|
t.Errorf("Record.Name = %q, want www.example.com.", got)
|
|
}
|
|
if !strings.HasPrefix(z.Records[0].Text(), "WWW.example.com.") {
|
|
t.Errorf("Text() = %q, want the owner as written", z.Records[0].Text())
|
|
}
|
|
}
|
|
|
|
func TestParseParenthesised(t *testing.T) {
|
|
t.Run("multi line soa", func(t *testing.T) {
|
|
z := mustParse(t, `@ SOA ns1.example.com. hostmaster.example.com. (
|
|
2024010101
|
|
7200
|
|
3600
|
|
1209600
|
|
300 )
|
|
`, Options{Origin: "example.com."})
|
|
if z.SOA == nil {
|
|
t.Fatal("SOA = nil")
|
|
}
|
|
if z.SOA.Serial != 2024010101 || z.SOA.Refresh != 7200 || z.SOA.Minttl != 300 {
|
|
t.Errorf("SOA = %v, want serial 2024010101, refresh 7200, minimum 300", z.SOA)
|
|
}
|
|
if z.Records[0].Line != 1 {
|
|
t.Errorf("Line = %d, want 1 (the start of the folded record)", z.Records[0].Line)
|
|
}
|
|
})
|
|
|
|
t.Run("comment inside parentheses", func(t *testing.T) {
|
|
z := mustParse(t, "host TXT ( \"one\" ; first\n \"two\" ; second\n )\n", Options{Origin: "example.com."})
|
|
if len(z.Records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(z.Records))
|
|
}
|
|
if got := summaryNoTTL(t, z)[0]; !strings.HasSuffix(got, `IN TXT "one" "two"`) {
|
|
t.Errorf("record = %q, want a two-string TXT", got)
|
|
}
|
|
})
|
|
|
|
t.Run("parentheses on a single line", func(t *testing.T) {
|
|
z := mustParse(t, "host MX ( 10 mx1.example.com. )\n", Options{Origin: "example.com."})
|
|
if len(z.Records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(z.Records))
|
|
}
|
|
if got := summaryNoTTL(t, z)[0]; !strings.HasSuffix(got, "IN MX 10 mx1.example.com.") {
|
|
t.Errorf("record = %q, want one MX rdata field", got)
|
|
}
|
|
})
|
|
|
|
t.Run("parentheses are stripped from the assembled line", func(t *testing.T) {
|
|
lineno := 0
|
|
lines := bufio.NewScanner(strings.NewReader("host MX 10 mx1.example.com.\n"))
|
|
got, err := fold(lines, "host MX ( 10 mx1.example.com. )", &lineno, Options{})
|
|
if err != nil {
|
|
t.Fatalf("fold: %v", err)
|
|
}
|
|
if strings.ContainsAny(got, "()") {
|
|
t.Errorf("fold = %q, want no parentheses", got)
|
|
}
|
|
// The parens and the whitespace they wrapped are gone; the token
|
|
// stream is what the record parser cares about, not the exact spacing.
|
|
if want := []string{"host", "MX", "10", "mx1.example.com."}; !slices.Equal(strings.Fields(got), want) {
|
|
t.Errorf("fold = %q, tokens = %v, want %v", got, strings.Fields(got), want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseOriginDirective(t *testing.T) {
|
|
t.Run("relative names follow $ORIGIN", func(t *testing.T) {
|
|
z := mustParse(t, "$ORIGIN sub.example.com.\nwww IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
|
if z.Origin != "sub.example.com." {
|
|
t.Errorf("Origin = %q, want sub.example.com.", z.Origin)
|
|
}
|
|
if z.Records[0].Name != "www.sub.example.com." {
|
|
t.Errorf("name = %q, want www.sub.example.com.", z.Records[0].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("absolute names are left alone", func(t *testing.T) {
|
|
z := mustParse(t, "$ORIGIN sub.example.com.\nwww.other.example. IN A 192.0.2.1\n", Options{})
|
|
if z.Records[0].Name != "www.other.example." {
|
|
t.Errorf("name = %q, want www.other.example.", z.Records[0].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("no origin at all is the root", func(t *testing.T) {
|
|
z := mustParse(t, "host IN A 192.0.2.1\n", Options{})
|
|
if z.Origin != "." {
|
|
t.Errorf("Origin = %q, want .", z.Origin)
|
|
}
|
|
if z.Records[0].Name != "host." {
|
|
t.Errorf("name = %q, want host.", z.Records[0].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("relative names at the root get no suffix", func(t *testing.T) {
|
|
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "."})
|
|
if z.Records[0].Name != "host." {
|
|
t.Errorf("name = %q, want host.", z.Records[0].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("$ORIGIN without a name is rejected", func(t *testing.T) {
|
|
// BIND resets to the zone origin when $ORIGIN carries no argument;
|
|
// this parser treats it as an error instead.
|
|
err := parseErr(t, "$ORIGIN example.com.\n$ORIGIN\n", Options{})
|
|
if !strings.Contains(err.Error(), "$ORIGIN needs a name") {
|
|
t.Errorf("error = %q, want it to mention \"$ORIGIN needs a name\"", err)
|
|
}
|
|
})
|
|
|
|
t.Run("origin option applies when the file has no $ORIGIN", func(t *testing.T) {
|
|
z := mustParse(t, "www IN A 192.0.2.1\n", Options{Origin: "example.com"})
|
|
if z.Origin != "example.com." || z.Records[0].Name != "www.example.com." {
|
|
t.Errorf("origin = %q, name = %q", z.Origin, z.Records[0].Name)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A domain name in the RDATA section that is written without a trailing dot is
|
|
// relative to the zone origin (RFC 1035 §5.1). The qualification has to happen
|
|
// on the token stream, before dns.NewRR: the library resolves RDATA names
|
|
// against the root, so afterwards "host" and "host." are indistinguishable and
|
|
// a relative CNAME target would silently point at the root instead.
|
|
func TestParseRelativeRdata(t *testing.T) {
|
|
// The owners are deliberately not RR type names: a leading token that names
|
|
// a type reads as an omitted owner, as assemble documents.
|
|
z := mustParse(t, `$ORIGIN example.com.
|
|
cn 300 IN CNAME host
|
|
ns1sub 300 IN NS ns1.sub
|
|
ptral 300 IN PTR host
|
|
mail 300 IN MX 10 mailhost
|
|
srvone 300 IN SRV 0 1 5060 sip
|
|
napone 300 IN NAPTR 100 50 "s" "http+I2R" "" _http._tcp
|
|
svcone 300 IN SVCB 1 _sip._tcp
|
|
zone1 300 IN SOA ns1 hostmaster 1 3600 600 604800 300
|
|
abs 300 IN CNAME host.other.test.
|
|
nullmx 300 IN MX 10 .
|
|
txtone 300 IN TXT "host"
|
|
`, Options{Origin: "example.com."})
|
|
checkSummary(t, z, []string{
|
|
`2 cn.example.com. 300 IN CNAME host.example.com.`,
|
|
`3 ns1sub.example.com. 300 IN NS ns1.sub.example.com.`,
|
|
`4 ptral.example.com. 300 IN PTR host.example.com.`,
|
|
`5 mail.example.com. 300 IN MX 10 mailhost.example.com.`,
|
|
`6 srvone.example.com. 300 IN SRV 0 1 5060 sip.example.com.`,
|
|
`7 napone.example.com. 300 IN NAPTR 100 50 "s" "http+I2R" "" _http._tcp.example.com.`,
|
|
`8 svcone.example.com. 300 IN SVCB 1 _sip._tcp.example.com.`,
|
|
`9 zone1.example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 1 3600 600 604800 300`,
|
|
`10 abs.example.com. 300 IN CNAME host.other.test.`,
|
|
`11 nullmx.example.com. 300 IN MX 10 .`,
|
|
`12 txtone.example.com. 300 IN TXT "host"`,
|
|
})
|
|
}
|
|
|
|
// Each line is assembled on its own and fold continues a statement only while
|
|
// parentheses are open, so a record never swallows the line after it -- whatever
|
|
// its token count is.
|
|
func TestParseRecordsDoNotRunTogether(t *testing.T) {
|
|
z := mustParse(t, `$ORIGIN example.com.
|
|
ns1 A 192.0.2.53
|
|
note TXT "hello world"
|
|
@ IN SOA ns1 hostmaster 1 3600 600 604800 300
|
|
mail MX 10 mxhost
|
|
`, Options{})
|
|
if got, want := summaryNoTTL(t, z), []string{
|
|
`2 ns1.example.com. IN A 192.0.2.53`,
|
|
`3 note.example.com. IN TXT "hello world"`,
|
|
`4 example.com. IN SOA ns1.example.com. hostmaster.example.com. 1 3600 600 604800 300`,
|
|
`5 mail.example.com. IN MX 10 mxhost.example.com.`,
|
|
}; !slices.Equal(got, want) {
|
|
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseClassHandling(t *testing.T) {
|
|
z := mustParse(t, `one IN A 192.0.2.1
|
|
two in A 192.0.2.2
|
|
three A 192.0.2.3
|
|
four 300 in A 192.0.2.4
|
|
five ch A 192.0.2.5
|
|
six hs A 192.0.2.6
|
|
seven 300 A 192.0.2.7
|
|
`, Options{Origin: "example.com."})
|
|
if len(z.Records) != 7 {
|
|
t.Fatalf("got %d records, want 7", len(z.Records))
|
|
}
|
|
// An omitted class defaults to IN; "in" and "IN" are the same token.
|
|
want := []uint16{
|
|
dns.ClassINET, dns.ClassINET, dns.ClassINET, dns.ClassINET,
|
|
dns.ClassCHAOS, dns.ClassHESIOD, dns.ClassINET,
|
|
}
|
|
for i, rec := range z.Records {
|
|
if rec.RR.Header().Class != want[i] {
|
|
t.Errorf("%q has class %s, want %s", rec.Text(),
|
|
dns.ClassToString[rec.RR.Header().Class], dns.ClassToString[want[i]])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseCommentsAndBlankLines(t *testing.T) {
|
|
z := mustParse(t, `; leading comment
|
|
$TTL 1h
|
|
|
|
; indented comment
|
|
|
|
one 300 IN A 192.0.2.1 ; trailing comment
|
|
|
|
|
|
two 300 IN A 192.0.2.2 ;comment without space
|
|
three 300 IN TXT "semi ; colon" ; real comment
|
|
`, Options{Origin: "example.com."})
|
|
if got := summary(t, z); !slices.Equal(got, []string{
|
|
`6 one.example.com. 300 IN A 192.0.2.1`,
|
|
`9 two.example.com. 300 IN A 192.0.2.2`,
|
|
`10 three.example.com. 300 IN TXT "semi ; colon"`,
|
|
}) {
|
|
t.Errorf("records = %q, want three records on lines 6, 9 and 10", got)
|
|
}
|
|
}
|
|
|
|
func TestParseErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
src string
|
|
opt Options
|
|
wantSub string
|
|
}{
|
|
{name: "no type", src: "host 3600\n", wantSub: "has no type"},
|
|
{name: "bad token before type", src: "host BOGUS 1.2.3.4\n", wantSub: `unexpected token "BOGUS"`},
|
|
{name: "bad rdata", src: "host 3600 IN A not-an-ip\n"},
|
|
{name: "unterminated parenthesis", src: "host 3600 IN A ( 192.0.2.1\n", wantSub: "unterminated parenthesis"},
|
|
{name: "unterminated parenthesis with a blank line", src: "host 3600 IN A (\n\n 192.0.2.1\n", wantSub: "unterminated parenthesis"},
|
|
{name: "unterminated quote", src: "host 3600 IN TXT \"unterminated\n"},
|
|
{name: "no owner and no previous owner", src: "A 192.0.2.1\n", wantSub: "no owner name"},
|
|
{name: "bad ttl directive", src: "$TTL forever\n", wantSub: "bad TTL"},
|
|
{name: "ttl directive without a value", src: "$TTL\n", wantSub: "$TTL needs a value"},
|
|
{name: "origin directive without a name", src: "$ORIGIN\n", wantSub: "$ORIGIN needs a name"},
|
|
{name: "include disabled", src: "$INCLUDE other.zone\n", wantSub: "disabled"},
|
|
{name: "include unsupported", src: "$INCLUDE other.zone\n", opt: Options{MaxIncludeDepth: 3}, wantSub: "not supported"},
|
|
{name: "include without a name", src: "$INCLUDE\n", wantSub: "$INCLUDE needs a file name"},
|
|
{name: "error carries file and line", src: "host 3600 IN A 192.0.2.1\nother 3600 IN A nope\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
|
|
{name: "directive error carries the line", src: "$TTL 1h\n$TTL\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
|
|
{name: "read error is reported", src: "host 3600 IN A 192.0.2.1\n", opt: Options{File: "bad.zone"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.name == "read error is reported" {
|
|
// A reader that fails after the first line exercises the
|
|
// lines.Err() path in Parse.
|
|
if _, err := Parse(&failingReader{}, Options{File: "bad.zone"}); err == nil {
|
|
t.Fatal("Parse with a failing reader: want an error")
|
|
}
|
|
return
|
|
}
|
|
err := parseErr(t, tc.src, tc.opt)
|
|
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
|
|
t.Errorf("error = %q, want it to contain %q", err, tc.wantSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// failingReader fails on the first Read so Parse must surface the error.
|
|
type failingReader struct{}
|
|
|
|
func (failingReader) Read([]byte) (int, error) { return 0, errRead }
|
|
|
|
var errRead = fmt.Errorf("synthetic read failure")
|
|
|
|
func TestParseEmptyFile(t *testing.T) {
|
|
for _, src := range []string{"", "\n\n", " \n\t\n", "; only a comment\n"} {
|
|
z := mustParse(t, src, Options{Origin: "example.com."})
|
|
if len(z.Records) != 0 {
|
|
t.Errorf("Parse(%q) returned %d records, want 0", src, len(z.Records))
|
|
}
|
|
if z.SOA != nil {
|
|
t.Errorf("Parse(%q) returned an SOA, want nil", src)
|
|
}
|
|
if z.Origin != "example.com." {
|
|
t.Errorf("Parse(%q) origin = %q, want example.com.", src, z.Origin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseFileAndParseFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := filepath.Join(dir, "a.zone")
|
|
b := filepath.Join(dir, "b.zone")
|
|
if err := os.WriteFile(a, []byte("$TTL 1h\nwww IN A 192.0.2.1\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(b, []byte("$TTL 2h\nmail IN A 192.0.2.2\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
z, err := ParseFile(a, "example.com.")
|
|
if err != nil {
|
|
t.Fatalf("ParseFile: %v", err)
|
|
}
|
|
if z.File != a || z.Origin != "example.com." || len(z.Records) != 1 {
|
|
t.Fatalf("ParseFile = %+v", z)
|
|
}
|
|
if z.Records[0].Name != "www.example.com." {
|
|
t.Errorf("name = %q, want www.example.com.", z.Records[0].Name)
|
|
}
|
|
if z.Records[0].File != a {
|
|
t.Errorf("record File = %q, want %q", z.Records[0].File, a)
|
|
}
|
|
if _, err := ParseFile(filepath.Join(dir, "missing.zone"), "example.com."); err == nil {
|
|
t.Error("ParseFile on a missing file: want an error")
|
|
}
|
|
|
|
merged, err := ParseFiles([]string{a, b}, OriginFromPath)
|
|
if err != nil {
|
|
t.Fatalf("ParseFiles: %v", err)
|
|
}
|
|
if len(merged.Records) != 2 {
|
|
t.Fatalf("ParseFiles returned %d records, want 2", len(merged.Records))
|
|
}
|
|
if merged.DefaultTTL != 7200 {
|
|
t.Errorf("merged DefaultTTL = %d, want 7200 (the last file wins)", merged.DefaultTTL)
|
|
}
|
|
if _, err := ParseFiles([]string{a, filepath.Join(dir, "missing.zone")}, OriginFromPath); err == nil {
|
|
t.Error("ParseFiles with a missing file: want an error")
|
|
}
|
|
}
|
|
|
|
func TestOriginFromPath(t *testing.T) {
|
|
tests := []struct{ in, want string }{
|
|
{"ads.zone", "ads."},
|
|
{"rpz/ads.zone", "ads."},
|
|
{"plain", "plain."},
|
|
{".hidden", ".hidden."},
|
|
{"/var/lib/fancy-dns/ads.zone", "ads."},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := OriginFromPath(tc.in); got != tc.want {
|
|
t.Errorf("OriginFromPath(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnsureDot(t *testing.T) {
|
|
tests := []struct{ in, want string }{
|
|
{"", ""},
|
|
{".", "."},
|
|
{"example.com", "example.com."},
|
|
{"example.com.", "example.com."},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := ensureDot(tc.in); got != tc.want {
|
|
t.Errorf("ensureDot(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRelative(t *testing.T) {
|
|
tests := []struct{ name, origin, want string }{
|
|
{"@", "example.com.", "example.com."},
|
|
{"www", "example.com.", "www.example.com."},
|
|
{"www.other.", "example.com.", "www.other."},
|
|
{"www", ".", "www."},
|
|
{"*", "example.com.", "*.example.com."},
|
|
{"www", "", "www."},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := relative(tc.name, tc.origin); got != tc.want {
|
|
t.Errorf("relative(%q, %q) = %q, want %q", tc.name, tc.origin, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStripComment(t *testing.T) {
|
|
tests := []struct{ in, want string }{
|
|
{"a ; comment", "a "},
|
|
{"a;comment", "a"},
|
|
{`a "has ; semicolon" b`, `a "has ; semicolon" b`},
|
|
{`a \; escaped`, `a \; escaped`},
|
|
{"no comment here", "no comment here"},
|
|
{`"unterminated ; quote`, `"unterminated ; quote`},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := stripComment(tc.in); got != tc.want {
|
|
t.Errorf("stripComment(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitFields(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
want []string
|
|
}{
|
|
{"a b c", []string{"a", "b", "c"}},
|
|
{`a "b c" d`, []string{"a", `"b c"`, "d"}},
|
|
{" ", nil},
|
|
{`a\ b c`, []string{`a\ b`, "c"}},
|
|
{"a\tb", []string{"a", "b"}},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := splitFields(tc.in); !slices.Equal(got, tc.want) {
|
|
t.Errorf("splitFields(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeparen(t *testing.T) {
|
|
tests := []struct{ in, want string }{
|
|
{"a ( b", "a b"},
|
|
{"a ) b", "a b"},
|
|
{"a b", "a b"},
|
|
{"()", ""},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := deparen(tc.in); got != tc.want {
|
|
t.Errorf("deparen(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTokenClassification(t *testing.T) {
|
|
for _, s := range []string{"0", "300", "1h30m", "300s"} {
|
|
if !isTTLToken(s) {
|
|
t.Errorf("isTTLToken(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"", "-1", "IN", "in", "CH", "HS", "1x", "forever"} {
|
|
if isTTLToken(s) {
|
|
t.Errorf("isTTLToken(%q) = true, want false", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"IN", "in", "In", "CH", "ch", "HS", "hs"} {
|
|
if !isClassToken(s) {
|
|
t.Errorf("isClassToken(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"", "300", "ANY", "cs", "in4"} {
|
|
if isClassToken(s) {
|
|
t.Errorf("isClassToken(%q) = true, want false", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"A", "a", "AAAA", "ns", "CNAME", "SOA", "ANY", "SVCB", "HTTPS", "nsec3"} {
|
|
if !isRRTYPE(s) {
|
|
t.Errorf("isRRTYPE(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"", "www", "IN", "300", "BOGUS", "www.example.com."} {
|
|
if isRRTYPE(s) {
|
|
t.Errorf("isRRTYPE(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCloneSOA(t *testing.T) {
|
|
if cloneSOA(nil) != nil {
|
|
t.Error("cloneSOA(nil) != nil")
|
|
}
|
|
in := &dns.SOA{Hdr: dns.RR_Header{Name: "example.com."}, Serial: 7, Minttl: 60}
|
|
out := cloneSOA(in)
|
|
if out == in {
|
|
t.Fatal("cloneSOA returned the same pointer")
|
|
}
|
|
if out.Serial != 7 || out.Minttl != 60 {
|
|
t.Errorf("cloneSOA copied %+v", out)
|
|
}
|
|
}
|
|
|
|
func TestWhereAndDirOf(t *testing.T) {
|
|
if got := where(""); got != "zone" {
|
|
t.Errorf("where(\"\") = %q, want zone", got)
|
|
}
|
|
if got := where("a.zone"); got != "a.zone" {
|
|
t.Errorf("where(a.zone) = %q", got)
|
|
}
|
|
if got := dirOf("a.zone"); got != "." {
|
|
t.Errorf("dirOf(a.zone) = %q, want .", got)
|
|
}
|
|
if got := dirOf("/x/y/a.zone"); got != "/x/y" {
|
|
t.Errorf("dirOf(/x/y/a.zone) = %q", got)
|
|
}
|
|
}
|