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) } }) } 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) } }