package blacklist import ( "strings" "testing" "github.com/owen/vibedns/internal/models" ) func TestMatchExactAndSubdomains(t *testing.T) { b := NewBuilder(1, "Test", models.KindBlacklist, 4) b.Add("example.com", true) // covers subdomains b.Add("exact.example.net", false) // this name only b.Add("*.wild.example", false) // wildcard syntax implies subdomains set := b.Build() tests := []struct { name string query string want bool }{ {"exact match on a subdomain entry", "example.com", true}, {"one level down", "www.example.com", true}, {"several levels down", "a.b.c.example.com", true}, {"trailing dot is ignored", "www.example.com.", true}, {"case is ignored", "WWW.Example.COM", true}, {"sibling is not matched", "notexample.com", false}, {"parent is not matched", "com", false}, {"exact-only entry matches itself", "exact.example.net", true}, {"exact-only entry does not cover subdomains", "www.exact.example.net", false}, {"wildcard entry matches the base", "wild.example", true}, {"wildcard entry covers subdomains", "anything.wild.example", true}, {"unlisted name", "example.org", false}, {"empty query", "", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { matched, ok := set.Match(tc.query) if ok != tc.want { t.Errorf("Match(%q) = %v (matched %q), want %v", tc.query, ok, matched, tc.want) } }) } } // TestSubdomainCoverageIsNotStored is the memory property the design depends // on: covering every subdomain must not cost an entry per subdomain. func TestSubdomainCoverageIsNotStored(t *testing.T) { b := NewBuilder(1, "Test", models.KindBlacklist, 1) b.Add("example.com", true) set := b.Build() if set.Len() != 1 { t.Fatalf("stored %d entries, want exactly 1", set.Len()) } for _, name := range []string{ "a.example.com", "b.a.example.com", "c.b.a.example.com", "very.deeply.nested.name.example.com", } { if _, ok := set.Match(name); !ok { t.Errorf("%s should be covered by the single stored entry", name) } } } func TestEmptySetMatchesNothing(t *testing.T) { set := NewBuilder(1, "Empty", models.KindBlacklist, 0).Build() if _, ok := set.Match("example.com"); ok { t.Error("an empty set must not match") } var nilSet *Set if _, ok := nilSet.Match("example.com"); ok { t.Error("a nil set must not match") } } func TestParsePlainList(t *testing.T) { input := `# A comment example.com bad.example tracker.example.net ; another comment style ! adblock comment ` domains, summary := ParseString(input, ParseOptions{DefaultMatchSubdomains: true}) want := []string{"example.com", "bad.example", "tracker.example.net"} if len(domains) != len(want) { t.Fatalf("parsed %d domains, want %d: %v", len(domains), len(want), domains) } for i, d := range domains { if d.Domain != want[i] { t.Errorf("domain[%d] = %q, want %q", i, d.Domain, want[i]) } } if summary.Imported != 3 { t.Errorf("imported = %d, want 3", summary.Imported) } if summary.Ignored != 4 { t.Errorf("ignored = %d, want 4 comments and blanks", summary.Ignored) } } func TestParseHostsFile(t *testing.T) { input := `# Hosts-style blocklist 0.0.0.0 example.com 127.0.0.1 tracker.example.net :: bad.example 0.0.0.0 multi-a.example multi-b.example 127.0.0.1 localhost ::1 ip6-localhost 0.0.0.0 192.168.1.1 printer.local ` domains, summary := ParseString(input, ParseOptions{DefaultMatchSubdomains: true}) got := map[string]bool{} for _, d := range domains { got[d.Domain] = true } for _, want := range []string{ "example.com", "tracker.example.net", "bad.example", "multi-a.example", "multi-b.example", "printer.local", } { if !got[want] { t.Errorf("expected %q to be imported; got %v", want, keys(got)) } } // Loopback names are hosts-file boilerplate, not blockable domains. for _, unwanted := range []string{"localhost", "ip6-localhost"} { if got[unwanted] { t.Errorf("%q should not have been imported", unwanted) } } if summary.LinesProcessed != 9 { t.Errorf("lines processed = %d, want 9", summary.LinesProcessed) } } func TestParseAdblockRules(t *testing.T) { input := `[Adblock Plus 2.0] ||ads.example.com^ ||tracker.example.net^$third-party @@||allowed.example.com^ ||example.org/path/to/thing ##.banner-class ` domains, _ := ParseString(input, ParseOptions{}) got := map[string]bool{} for _, d := range domains { got[d.Domain] = true } if !got["ads.example.com"] { t.Error("a plain ||domain^ rule should be imported") } if !got["tracker.example.net"] { t.Error("a ||domain^ rule with options should import the domain part") } if got["allowed.example.com"] { t.Error("an @@ exception rule must not become a block entry") } // An Adblock host rule implies subdomain coverage. for _, d := range domains { if d.Domain == "ads.example.com" && !d.MatchSubdomains { t.Error("an Adblock host rule should cover subdomains") } } } func TestParseDeduplicatesWithinFile(t *testing.T) { input := "example.com\nexample.com\nEXAMPLE.COM\nexample.com.\n" domains, summary := ParseString(input, ParseOptions{}) if len(domains) != 1 { t.Errorf("parsed %d domains, want 1 after normalisation", len(domains)) } if summary.Duplicates != 3 { t.Errorf("duplicates = %d, want 3", summary.Duplicates) } } func TestParseRejectsInvalidEntries(t *testing.T) { input := "example.com\nnot a domain at all\n-bad-.example\nvalid.example\n" domains, summary := ParseString(input, ParseOptions{}) got := map[string]bool{} for _, d := range domains { got[d.Domain] = true } if !got["example.com"] || !got["valid.example"] { t.Errorf("valid entries were dropped: %v", keys(got)) } if summary.Invalid == 0 { t.Error("expected invalid entries to be counted") } if len(summary.InvalidSamples) == 0 { t.Error("expected a sample of the rejected lines for the operator") } } func TestParseIgnoresIPOnlyAndSingleLabel(t *testing.T) { input := "192.0.2.1\nlocalhost\ncom\nvalid.example\n" domains, _ := ParseString(input, ParseOptions{}) for _, d := range domains { if d.Domain != "valid.example" { t.Errorf("unexpected import %q", d.Domain) } } } func TestParseLargeInput(t *testing.T) { // A realistic blocklist shape: confirm parsing scales and counts correctly. var b strings.Builder const n = 50000 for i := 0; i < n; i++ { b.WriteString("0.0.0.0 host") b.WriteString(itoa(i)) b.WriteString(".example.com\n") } domains, summary := ParseString(b.String(), ParseOptions{DefaultMatchSubdomains: true}) if len(domains) != n { t.Errorf("parsed %d domains, want %d", len(domains), n) } if summary.Imported != n { t.Errorf("imported = %d, want %d", summary.Imported, n) } if summary.Invalid != 0 { t.Errorf("invalid = %d, want 0", summary.Invalid) } } func keys(m map[string]bool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out } func itoa(i int) string { if i == 0 { return "0" } var buf [12]byte pos := len(buf) for i > 0 { pos-- buf[pos] = byte('0' + i%10) i /= 10 } return string(buf[pos:]) }