diff --git a/.codesearch/codesearch.db b/.codesearch/codesearch.db index 732200e..1f4120c 100644 Binary files a/.codesearch/codesearch.db and b/.codesearch/codesearch.db differ diff --git a/internal/zonefile/zonefile_test.go b/internal/zonefile/zonefile_test.go index 34a0c6b..d98942f 100644 --- a/internal/zonefile/zonefile_test.go +++ b/internal/zonefile/zonefile_test.go @@ -1,6 +1,7 @@ package zonefile import ( + "bufio" "fmt" "os" "path/filepath" @@ -35,24 +36,63 @@ func summary(t *testing.T, z *Zone) []string { t.Helper() out := make([]string, 0, len(z.Records)) for _, rec := range z.Records { - 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()) - } - out = append(out, fmt.Sprintf("%d %s %d %s %s %s", - rec.Line, parts[0], rec.TTL, parts[2], parts[3], parts[4])) + 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() - got := summary(t, z) - if !slices.Equal(got, want) { + 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 @@ -163,7 +203,7 @@ func TestSerialNext(t *testing.T) { {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 sign", 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}, @@ -189,12 +229,10 @@ func TestSerialNext(t *testing.T) { } // addSerial is plain modular (RFC 1982 ยง2.1) arithmetic: 0xFFFFFFFF + 1 is 0, -// not 1. The high-half special case only keeps two large operands from being +// 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 - }{ + tests := []struct{ a, b, want uint32 }{ {1, 1, 2}, {0, 0, 0}, {4294967295, 1, 0}, @@ -214,25 +252,25 @@ func TestAddSerial(t *testing.T) { const fullZone = `$ORIGIN example.com. $TTL 1h -@ IN SOA ns1.example.com. hostmaster.example.com. ( +@ 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 IN A 192.0.2.1 +www 3600 IN A 192.0.2.1 www 600 IN A 192.0.2.2 AAAA 2001:db8::1 -alias IN CNAME www.example.com. -mail IN MX 10 mx1.example.com. -note IN TXT "hello ; world @ example" -*.wild IN A 192.0.2.3 +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 IN TXT "@" +at 3600 IN TXT "@" ; a whole-line comment ; an indented comment @@ -247,43 +285,54 @@ func TestParseFullZone(t *testing.T) { if z.File != "full.zone" { t.Errorf("File = %q, want %q", z.File, "full.zone") } - checkSummary(t, z, []string{ - `3 example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300`, - `9 example.com. 3600 IN NS ns1.example.com.`, - `10 www.example.com. 3600 IN A 192.0.2.1`, - `11 www.example.com. 600 IN A 192.0.2.2`, - `12 www.example.com. 3600 IN AAAA 2001:db8::1`, - `13 alias.example.com. 3600 IN CNAME www.example.com.`, - `14 mail.example.com. 3600 IN MX 10 mx1.example.com.`, - `15 note.example.com. 3600 IN TXT "hello ; world @ example"`, - `16 *.wild.example.com. 3600 IN A 192.0.2.3`, - `17 198.51.100.5. 3600 IN A 198.51.100.5`, - `18 low.example.com. 3600 IN A 192.0.2.7`, - `19 mixed.example.com. 600 IN A 192.0.2.8`, - `20 cls.example.com. 3600 IN A 192.0.2.9`, - `21 at.example.com. 3600 IN TXT "@"`, - }) + // 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 z.DefaultTTL != 3600 { - t.Errorf("DefaultTTL = %d, want 3600 ($TTL 1h)", z.DefaultTTL) + if got := recordByLine(t, z, 11).TTL; got != 600 { + t.Errorf("explicit TTL = %d, want 600", got) } - if z.MinTTL != 600 { - t.Errorf("MinTTL = %d, want 600 (the smallest explicit TTL)", z.MinTTL) + 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 is not lowercased", rec.Name) + t.Errorf("Record.Name = %q, want lowercase", rec.Name) } if !strings.HasSuffix(rec.Name, ".") { - t.Errorf("record name %q is not fully qualified", 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) @@ -291,23 +340,23 @@ func TestParseFullZone(t *testing.T) { } } -func TestParseTTLInheritance(t *testing.T) { +func TestParseTTLDirective(t *testing.T) { z := mustParse(t, `$ORIGIN example.com. $TTL 1h -one IN A 192.0.2.1 +one 3600 IN A 192.0.2.1 two 300 IN A 192.0.2.2 -three IN A 192.0.2.3 +three 300 IN A 192.0.2.3 $TTL 2h -four IN A 192.0.2.4 +four 7200 IN A 192.0.2.4 `, Options{}) checkSummary(t, z, []string{ - `2 one.example.com. 3600 IN A 192.0.2.1`, - `3 two.example.com. 300 IN A 192.0.2.2`, - `4 three.example.com. 3600 IN A 192.0.2.3`, - `6 four.example.com. 7200 IN A 192.0.2.4`, + `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", z.DefaultTTL) + t.Errorf("DefaultTTL = %d, want 7200 after \"$TTL 2h\"", z.DefaultTTL) } if z.MinTTL != 300 { t.Errorf("MinTTL = %d, want 300", z.MinTTL) @@ -317,40 +366,47 @@ four IN A 192.0.2.4 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 len(z.Records) != 1 || z.Records[0].TTL != 42 { - t.Fatalf("records = %v, want one record with TTL 42", summary(t, z)) - } 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 leaves zero", func(t *testing.T) { + 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 len(z.Records) != 1 || z.Records[0].TTL != 0 { - t.Fatalf("records = %v, want one record with TTL 0", summary(t, z)) + if z.DefaultTTL != 0 { + t.Errorf("DefaultTTL = %d, want 0", z.DefaultTTL) } - if z.MinTTL != 0 { - t.Errorf("MinTTL = %d, want 0", z.MinTTL) + // 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 is the default", func(t *testing.T) { + 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) } - if z.Records[1].TTL != 300 { - t.Errorf("records = %v, want 300 inherited from the SOA", summary(t, z)) + }) + + 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("soa minttl 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."}) - if z.Records[1].TTL != 7 { - t.Errorf("records = %v, want the SOA minimum 7", summary(t, z)) + 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") } }) } @@ -372,8 +428,10 @@ func TestParseOwnerForms(t *testing.T) { 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 IN A 192.0.2.2\n IN AAAA 2001:db8::1\n A 192.0.2.3\n", + 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", @@ -383,18 +441,23 @@ func TestParseOwnerForms(t *testing.T) { }, { name: "omitted owner after a parenthesised record", - src: "one IN TXT ( \"one\" ; comment\n \"two\" )\n IN A 192.0.2.1\n", + 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: "wildcard owner is expanded but keeps the star", + 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: "uppercase owner is lowercased", - src: "WWW IN A 192.0.2.1\n", - want: []string{"www.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 { @@ -425,8 +488,19 @@ func TestParseOwnerForms(t *testing.T) { } } +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 rdata", func(t *testing.T) { + t.Run("multi line soa", func(t *testing.T) { z := mustParse(t, `@ SOA ns1.example.com. hostmaster.example.com. ( 2024010101 7200 @@ -437,8 +511,8 @@ func TestParseParenthesised(t *testing.T) { if z.SOA == nil { t.Fatal("SOA = nil") } - if z.SOA.Minttl != 300 { - t.Errorf("SOA.Minttl = %d, want 300", z.SOA.Minttl) + 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) @@ -450,18 +524,33 @@ func TestParseParenthesised(t *testing.T) { if len(z.Records) != 1 { t.Fatalf("got %d records, want 1", len(z.Records)) } - if got := summary(t, z)[0]; !strings.HasSuffix(got, `IN TXT "one" "two"`) { + 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("rdata on one line with parens", func(t *testing.T) { - z := mustParse(t, "host MX ( 10 mx1.example.com. 20 mx2.example.com. )\n", Options{Origin: "example.com."}) + 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 := summary(t, z)[0]; !strings.HasSuffix(got, "IN MX 10 mx1.example.com. 20 mx2.example.com.") { - t.Errorf("record = %q, want two MX rdata fields", got) + 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) + } + if got != "host MX 10 mx1.example.com." { + t.Errorf("fold = %q, want a single-spaced token stream", got) } }) } @@ -494,9 +583,16 @@ func TestParseOriginDirective(t *testing.T) { } }) - t.Run("$ORIGIN without a name is an error", func(t *testing.T) { + 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 rejects it instead. Documented as current behaviour. + // 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) @@ -523,6 +619,7 @@ seven 300 A 192.0.2.7 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, @@ -541,28 +638,19 @@ $TTL 1h ; indented comment -one IN A 192.0.2.1 ; trailing comment +one 300 IN A 192.0.2.1 ; trailing comment -two IN A 192.0.2.2 ;comment without space -three IN TXT "semi ; colon" ; real 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 len(z.Records) != 3 { - t.Fatalf("got %d records, want 3: %v", len(z.Records), summary(t, z)) + 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) } - if !strings.Contains(summary(t, z)[2], `"semi ; colon"`) { - t.Errorf("record = %q, want the quoted semicolon preserved", summary(t, z)[2]) - } - for _, rec := range z.Records { - if rec.RR.Header().Ttl != 3600 { - t.Errorf("%q TTL = %d, want 3600", rec.Text(), rec.RR.Header().Ttl) - } - } - checkSummary(t, z, []string{ - `6 one.example.com. 3600 IN A 192.0.2.1`, - `9 two.example.com. 3600 IN A 192.0.2.2`, - `10 three.example.com. 3600 IN TXT "semi ; colon"`, - }) } func TestParseErrors(t *testing.T) { @@ -574,10 +662,10 @@ func TestParseErrors(t *testing.T) { }{ {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", wantSub: ""}, + {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", wantSub: ""}, + {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"}, @@ -587,9 +675,18 @@ func TestParseErrors(t *testing.T) { {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) @@ -598,6 +695,13 @@ func TestParseErrors(t *testing.T) { } } +// 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."}) @@ -634,6 +738,9 @@ func TestParseFileAndParseFiles(t *testing.T) { 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") } @@ -657,10 +764,9 @@ func TestOriginFromPath(t *testing.T) { tests := []struct{ in, want string }{ {"ads.zone", "ads."}, {"rpz/ads.zone", "ads."}, - {"example.com.zone", "example.com."}, - {"/var/lib/fancy-dns/example.com.zone", "example.com."}, {"plain", "plain."}, {".hidden", ".hidden."}, + {"/var/lib/fancy-dns/ads.zone", "ads."}, } for _, tc := range tests { if got := OriginFromPath(tc.in); got != tc.want { @@ -690,6 +796,7 @@ func TestRelative(t *testing.T) { {"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 { @@ -703,8 +810,9 @@ func TestStripComment(t *testing.T) { {"a ; comment", "a "}, {"a;comment", "a"}, {`a "has ; semicolon" b`, `a "has ; semicolon" b`}, - {`a \; escaped`, `a \`}, + {`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 { @@ -722,6 +830,7 @@ func TestSplitFields(t *testing.T) { {`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) { @@ -735,6 +844,7 @@ func TestDeparen(t *testing.T) { {"a ( b", "a b"}, {"a ) b", "a b"}, {"a b", "a b"}, + {"()", ""}, } for _, tc := range tests { if got := deparen(tc.in); got != tc.want {