more code
This commit is contained in:
1 parent
f24fd63e9a
commit
ee72257c64
29 files changed
+5636
-440
No files matched your search
@@ -0,0 +1,579 @@
|
||||
package blocklist
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ruleStrings renders parsed rules compactly for table comparison.
|
||||
func ruleStrings(rules []Rule) []string {
|
||||
out := make([]string, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
s := r.Name + " " + r.Action.String() + " ttl=" + strconv.Itoa(int(r.TTL))
|
||||
if r.Response != "" {
|
||||
s += " resp=" + r.Response
|
||||
}
|
||||
if r.Priority != 0 {
|
||||
s += " prio=" + strconv.Itoa(r.Priority)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameRules compares parsed rules against the expectation, treating "no rules"
|
||||
// as equal whether the parser returned a nil or an empty slice.
|
||||
func sameRules(got, want []string) bool {
|
||||
return strings.Join(got, "\n") == strings.Join(want, "\n")
|
||||
}
|
||||
|
||||
func TestParseHosts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
opt ParseOptions
|
||||
want []string
|
||||
wantFormat Format
|
||||
wantComment int
|
||||
wantSkip int
|
||||
wantBad int
|
||||
wantNotice string
|
||||
}{
|
||||
{
|
||||
name: "block entry",
|
||||
data: "0.0.0.0 ads.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
want: []string{"**.ads.example.com block ttl=60"},
|
||||
},
|
||||
{
|
||||
name: "several names on one line",
|
||||
data: "0.0.0.0 a.example.com b.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
want: []string{"**.a.example.com block ttl=60", "**.b.example.com block ttl=60"},
|
||||
},
|
||||
{
|
||||
name: "loopback bootstrap lines are never blocked",
|
||||
data: "127.0.0.1 localhost\n::1 ip6-localhost\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
wantSkip: 2,
|
||||
},
|
||||
{
|
||||
name: "comments, blank lines and inline comments",
|
||||
data: "# a comment\n; another\n\n \n0.0.0.0 ads.example.com # trailing note\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
want: []string{"**.ads.example.com block ttl=60"},
|
||||
wantComment: 2,
|
||||
},
|
||||
{
|
||||
name: "custom TTL and no subdomain expansion",
|
||||
data: "0.0.0.0 ads.example.com\n",
|
||||
opt: ParseOptions{Subdomains: false, DefaultTTL: 300},
|
||||
want: []string{"ads.example.com block ttl=300"},
|
||||
},
|
||||
{
|
||||
name: "a single field is not a hosts line",
|
||||
data: "ads.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
wantBad: 1,
|
||||
},
|
||||
{
|
||||
name: "a non-address first field falls back to a plain domain list",
|
||||
data: "0.0.0.0 ads.example.com\nfoo bar.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
// The fallback re-parses the whole file as a plain list, so the
|
||||
// real hosts lines survive and the odd line is malformed.
|
||||
want: []string{"**.ads.example.com block ttl=60"},
|
||||
wantFormat: FormatPlain,
|
||||
wantBad: 1,
|
||||
wantNotice: "no address in first field, falling back to a plain domain list",
|
||||
},
|
||||
{
|
||||
name: "malformed lines",
|
||||
data: "0.0.0.0\n0.0.0.0 not_a_domain..x\n0.0.0.0 -bad.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
wantBad: 3,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rules, stats, err := Parse(FormatHosts, []byte(tc.data), tc.opt)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, tc.want) {
|
||||
t.Errorf("rules = %v, want %v", got, tc.want)
|
||||
}
|
||||
wantFormat := tc.wantFormat
|
||||
if wantFormat == "" {
|
||||
wantFormat = FormatHosts
|
||||
}
|
||||
if stats.Format != wantFormat {
|
||||
t.Errorf("stats.Format = %q, want %q", stats.Format, wantFormat)
|
||||
}
|
||||
if stats.Comments != tc.wantComment {
|
||||
t.Errorf("Comments = %d, want %d", stats.Comments, tc.wantComment)
|
||||
}
|
||||
if stats.Skipped != tc.wantSkip {
|
||||
t.Errorf("Skipped = %d, want %d", stats.Skipped, tc.wantSkip)
|
||||
}
|
||||
if stats.Malformed != tc.wantBad {
|
||||
t.Errorf("Malformed = %d, want %d", stats.Malformed, tc.wantBad)
|
||||
}
|
||||
if stats.Rules != len(rules) {
|
||||
t.Errorf("Rules = %d, want %d", stats.Rules, len(rules))
|
||||
}
|
||||
if stats.Bytes != int64(len(tc.data)) {
|
||||
t.Errorf("Bytes = %d, want %d", stats.Bytes, len(tc.data))
|
||||
}
|
||||
if tc.wantNotice != "" {
|
||||
found := false
|
||||
for _, n := range stats.Notices {
|
||||
if n == tc.wantNotice {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Notices = %v, want %q", stats.Notices, tc.wantNotice)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdblock(t *testing.T) {
|
||||
data := strings.Join([]string{
|
||||
"[Adblock Plus 2.0]",
|
||||
"! Title: test list",
|
||||
"! Version: 1",
|
||||
"||ads.example.com^",
|
||||
"||track.example.com^$third-party",
|
||||
"|http://banner.example.com/img.png|",
|
||||
"|https://user:pw@secure.example.com:8443/x?y#z|",
|
||||
"@@||ok.example.com^",
|
||||
"example.com##.ad",
|
||||
"banner.example.com#@#.sponsor",
|
||||
"/ad[0-9]+/",
|
||||
"/banner[0-9]+/",
|
||||
"||",
|
||||
"bare.example.com",
|
||||
}, "\n")
|
||||
rules, stats, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"**.ads.example.com block ttl=60",
|
||||
"**.track.example.com block ttl=60",
|
||||
"**.banner.example.com block ttl=60",
|
||||
"**.secure.example.com block ttl=60",
|
||||
"**.ok.example.com allow ttl=60 prio=10",
|
||||
"**.bare.example.com block ttl=60",
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, want) {
|
||||
t.Errorf("rules = %v,\nwant %v", got, want)
|
||||
}
|
||||
if stats.Rules != len(want) {
|
||||
t.Errorf("Rules = %d, want %d", stats.Rules, len(want))
|
||||
}
|
||||
if stats.Comments != 3 { // the header plus two "! " comments
|
||||
t.Errorf("Comments = %d, want 3", stats.Comments)
|
||||
}
|
||||
// 2 cosmetic rules, 2 regex rules, 1 empty "||" host.
|
||||
if stats.Skipped != 5 {
|
||||
t.Errorf("Skipped = %d, want 5", stats.Skipped)
|
||||
}
|
||||
foundNotice := false
|
||||
for _, n := range stats.Notices {
|
||||
if n == "skipped regular-expression rules" {
|
||||
foundNotice = true
|
||||
}
|
||||
}
|
||||
if !foundNotice {
|
||||
t.Errorf("Notices = %v, want the regex notice", stats.Notices)
|
||||
}
|
||||
if stats.Format != FormatAdblock {
|
||||
t.Errorf("Format = %q", stats.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlain(t *testing.T) {
|
||||
data := strings.Join([]string{
|
||||
"# a comment",
|
||||
"; another comment",
|
||||
"",
|
||||
" spaced.example.com ",
|
||||
"!ok.example.com",
|
||||
"0.0.0.0 hoststyle.example.com",
|
||||
"||ads.example.com",
|
||||
"trailing.example.com.",
|
||||
"!not a domain line !!",
|
||||
"-bad.example.com",
|
||||
}, "\n")
|
||||
rules, stats, err := Parse(FormatPlain, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"**.spaced.example.com block ttl=60",
|
||||
"**.ok.example.com allow ttl=60",
|
||||
"**.hoststyle.example.com block ttl=60",
|
||||
"**.ads.example.com block ttl=60",
|
||||
// A trailing dot survives parsing; Builder.Add strips it later.
|
||||
"**.trailing.example.com. block ttl=60",
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, want) {
|
||||
t.Errorf("rules = %v,\nwant %v", got, want)
|
||||
}
|
||||
if stats.Comments != 2 {
|
||||
t.Errorf("Comments = %d, want 2", stats.Comments)
|
||||
}
|
||||
if stats.Malformed != 2 {
|
||||
t.Errorf("Malformed = %d, want 2", stats.Malformed)
|
||||
}
|
||||
|
||||
// Without subdomain expansion the same list yields exact rules.
|
||||
exact, _, err := Parse(FormatPlain, []byte("spaced.example.com\n!ok.example.com\n"), ParseOptions{Subdomains: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
wantExact := []string{"spaced.example.com block ttl=60", "ok.example.com allow ttl=60"}
|
||||
if got := ruleStrings(exact); !sameRules(got, wantExact) {
|
||||
t.Errorf("exact rules = %v, want %v", got, wantExact)
|
||||
}
|
||||
|
||||
// The documented "!allow <domain>" spelling is not what the parser
|
||||
// implements: it only strips the "!" marker, so the word "allow" is left
|
||||
// in the name and the line is counted as malformed. Real behaviour,
|
||||
// pinned so a fix shows up as a failure.
|
||||
allowForm, allowStats, err := Parse(FormatPlain, []byte("!allow ok.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
t.Logf("\"!allow ok.example.com\": rules=%v malformed=%d", ruleStrings(allowForm), allowStats.Malformed)
|
||||
if allowStats.Rules != 0 || allowStats.Malformed != 1 {
|
||||
t.Errorf("\"!allow x\": rules=%d malformed=%d, want 0/1 (current behaviour)",
|
||||
allowStats.Rules, allowStats.Malformed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRPZ(t *testing.T) {
|
||||
data := strings.Join([]string{
|
||||
"$TTL 3600",
|
||||
"; an RPZ policy zone",
|
||||
"example.com CNAME .",
|
||||
"*.sub.example.org CNAME rpz-drop.",
|
||||
"**.both.example.net CNAME rpz-passthru.",
|
||||
"redirect.example.gov A 127.0.0.2",
|
||||
"redirect6.example.gov AAAA ::1",
|
||||
"example.com CNAME .",
|
||||
`skip.example.com TXT "not-a-known-action"`,
|
||||
}, "\n")
|
||||
rules, stats, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
// A bare owner name becomes the STRICT-subdomain form "*." (RPZ
|
||||
// semantics: the policy covers what is under the name), unlike the
|
||||
// hosts parser which uses "**.". Zone owner names keep their
|
||||
// trailing dot; Builder.Add strips it at compile time.
|
||||
"*.example.com. nxdomain ttl=3600",
|
||||
"*.sub.example.org. drop ttl=3600",
|
||||
"**.both.example.net. passthrough ttl=3600",
|
||||
"*.redirect.example.gov. redirect ttl=3600 resp=127.0.0.2",
|
||||
"*.redirect6.example.gov. redirect ttl=3600 resp=::1",
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, want) {
|
||||
t.Errorf("rules = %v,\nwant %v", got, want)
|
||||
}
|
||||
if stats.Duplicates != 1 {
|
||||
t.Errorf("Duplicates = %d, want 1 (the repeated example.com record)", stats.Duplicates)
|
||||
}
|
||||
if stats.Skipped != 1 {
|
||||
t.Errorf("Skipped = %d, want 1 (TXT with an unknown action)", stats.Skipped)
|
||||
}
|
||||
if stats.Format != FormatRPZ {
|
||||
t.Errorf("Format = %q, want rpz", stats.Format)
|
||||
}
|
||||
|
||||
// Without subdomain expansion the wildcard prefix is left off entirely.
|
||||
plain, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := ruleStrings(plain); !sameRules(got, []string{"example.com. nxdomain ttl=60"}) {
|
||||
t.Errorf("no-subdomain rules = %v", got)
|
||||
}
|
||||
|
||||
// A CNAME to a real target is a rewrite rule and $TTL is honoured.
|
||||
cname, _, err := Parse(FormatRPZ, []byte("$TTL 60\nshop.example.com CNAME parked.example.net.\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := ruleStrings(cname); !sameRules(got, []string{"*.shop.example.com. cname ttl=60 resp=parked.example.net."}) {
|
||||
t.Errorf("cname rules = %v", got)
|
||||
}
|
||||
|
||||
// The compiled forms behave as documented: "*." skips the apex while
|
||||
// "**." covers it.
|
||||
s := mustBuild(rules...)
|
||||
if e := s.Match("example.com"); e != nil {
|
||||
t.Errorf("RPZ \"example.com CNAME .\" matched the apex: %q, want nil (strict \"*.\")", e.Name)
|
||||
}
|
||||
if e := s.Match("www.example.com"); e == nil || e.Action != ActionNXDomain {
|
||||
t.Errorf("www.example.com: %+v, want nxdomain", e)
|
||||
}
|
||||
if e := s.Match("both.example.net"); e == nil || e.Action != ActionPassthrough {
|
||||
t.Errorf("both.example.net: %+v, want passthrough (\"**.\" covers the apex)", e)
|
||||
}
|
||||
if e := s.Match("deep.both.example.net"); e == nil || e.Action != ActionPassthrough {
|
||||
t.Errorf("deep.both.example.net: %+v, want passthrough", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want Format
|
||||
}{
|
||||
{"hosts", "0.0.0.0 ads.example.com\n127.0.0.1 localhost\n", FormatHosts},
|
||||
{"hosts after comments", "# comment\n\n 127.0.0.1 localhost\n", FormatHosts},
|
||||
{"hosts with ::1", "::1 ip6-localhost\n", FormatHosts},
|
||||
{"adblock double pipe", "||ads.example.com^\n", FormatAdblock},
|
||||
{"adblock allow", "@@||ok.example.com^\n", FormatAdblock},
|
||||
{"adblock cosmetic", "example.com##.ad\n", FormatAdblock},
|
||||
{"adblock after header", "[Adblock Plus 2.0]\n! comment\n|http://a.example.com/x|\n", FormatAdblock},
|
||||
{"rpz ttl", "$TTL 3600\nexample.com CNAME .\n", FormatRPZ},
|
||||
{"rpz origin", "$ORIGIN example.com.\nexample.com CNAME .\n", FormatRPZ},
|
||||
{"rpz record with ttl and class", "example.com 3600 IN CNAME .\n", FormatRPZ},
|
||||
{"single plain domain", "ads.example.com\n", FormatPlain},
|
||||
{"empty", "", FormatPlain},
|
||||
{"only comments", "# nothing here\n; really\n", FormatPlain},
|
||||
// A two-line plain list is sniffed as adblock: the second meaningful
|
||||
// line is taken to be an adblock rule. Both parsers turn a bare
|
||||
// hostname into a block rule, so the rules still come out right.
|
||||
{"two plain domains", "ads.example.com\ntrack.example.com\n", FormatAdblock},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := DetectFormat([]byte(tc.data)); got != tc.want {
|
||||
t.Errorf("DetectFormat(%q) = %q, want %q", tc.data, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFormatSuspectedBugs(t *testing.T) {
|
||||
// Real behaviour today, pinned so a future fix shows up as a failure.
|
||||
// allDigitsOrName() does not know RR type names, so a policy record
|
||||
// without an explicit TTL/class column is sniffed as a plain domain
|
||||
// list. Parsing the same content as RPZ works, which isolates the
|
||||
// detection step.
|
||||
if got := DetectFormat([]byte("example.com CNAME .\n")); got != FormatPlain {
|
||||
t.Errorf("DetectFormat(rpz record) = %q, want plain (current behaviour)", got)
|
||||
}
|
||||
rules, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil || len(rules) != 1 {
|
||||
t.Errorf("explicit RPZ parse of the same record: rules=%v err=%v", ruleStrings(rules), err)
|
||||
}
|
||||
|
||||
// The "0 address" form that FormatHosts documents as accepted is not an
|
||||
// IP literal for netip, so the hosts parser counts the line as malformed
|
||||
// instead of blocking the name.
|
||||
_, stats, err := Parse(FormatHosts, []byte("0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if stats.Rules != 0 || stats.Malformed != 1 {
|
||||
t.Errorf("\"0 ads.example.com\": rules=%d malformed=%d, want 0/1 (current behaviour)",
|
||||
stats.Rules, stats.Malformed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoPicksFormat(t *testing.T) {
|
||||
rules, stats, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if stats.Format != FormatHosts {
|
||||
t.Errorf("Format = %q, want hosts", stats.Format)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].Name != "**.ads.example.com" {
|
||||
t.Errorf("rules = %v", ruleStrings(rules))
|
||||
}
|
||||
// An empty format string means "sniff" too.
|
||||
if _, stats, _ := Parse("", []byte("||ads.example.com^\n"), ParseOptions{Subdomains: true}); stats.Format != FormatAdblock {
|
||||
t.Errorf("Format = %q, want adblock", stats.Format)
|
||||
}
|
||||
if _, _, err := Parse("bogus", []byte("x"), ParseOptions{}); err == nil {
|
||||
t.Errorf("Parse with an unknown format returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRobustness(t *testing.T) {
|
||||
long := "long" + strings.Repeat("x", 100000) + ".example.com"
|
||||
bigDomain := strings.Repeat("d", 250) + ".example.com" // over the 253-char limit
|
||||
hugeDomain := strings.Repeat("d", 3000) + ".example.com"
|
||||
|
||||
listSamples := map[string]string{
|
||||
"crlf": "0.0.0.0 ads.example.com\r\n0.0.0.0 b.example.com\r\n",
|
||||
"lone cr": "0.0.0.0 ads.example.com\r0.0.0.0 b.example.com\r",
|
||||
"leading/trailing space": "\t 0.0.0.0 ads.example.com \n",
|
||||
"blank and space-only": "\n\n \n\t\n",
|
||||
"very long line": "0.0.0.0 " + long + "\n",
|
||||
"over-long label": "0.0.0.0 " + bigDomain + "\n",
|
||||
"comments everywhere": "# c\n; c\n0.0.0.0 ads.example.com # tail\n\n",
|
||||
}
|
||||
for name, data := range listSamples {
|
||||
t.Run("list/"+name, func(t *testing.T) {
|
||||
for _, format := range []Format{FormatHosts, FormatPlain, FormatAdblock} {
|
||||
rules, stats, err := Parse(format, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Errorf("%s: Parse: %v", format, err)
|
||||
continue
|
||||
}
|
||||
if stats.Rules != len(rules) {
|
||||
t.Errorf("%s: Rules = %d, want %d", format, stats.Rules, len(rules))
|
||||
}
|
||||
for _, r := range rules {
|
||||
if strings.ContainsAny(r.Name, "\r\n\t ") {
|
||||
t.Errorf("%s: rule name kept whitespace: %q", format, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The adblock and plain parsers must not be confused by CRLF either.
|
||||
for _, data := range []string{
|
||||
"||ads.example.com^\r\n@@||ok.example.com^\r\n",
|
||||
"a.example.com\r\nb.example.com\r\n",
|
||||
} {
|
||||
for _, format := range []Format{FormatPlain, FormatAdblock} {
|
||||
rules, _, err := Parse(format, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Errorf("%s: Parse(%q): %v", format, data, err)
|
||||
continue
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Errorf("%s: Parse(%q) produced no rules", format, data)
|
||||
}
|
||||
for _, r := range rules {
|
||||
if strings.ContainsAny(r.Name, "\r\n\t ") {
|
||||
t.Errorf("%s: rule name kept whitespace: %q", format, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Specifically: CRLF does not leak a CR into the rule names.
|
||||
rules, _, err := Parse(FormatHosts, []byte("0.0.0.0 ads.example.com\r\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].Name != "**.ads.example.com" {
|
||||
t.Errorf("CRLF rules = %v", ruleStrings(rules))
|
||||
}
|
||||
s := mustBuild(rules...)
|
||||
if s.Match("ads.example.com") == nil || s.Match("x.ads.example.com") == nil {
|
||||
t.Errorf("CRLF-parsed rule does not match: %v", ruleStrings(rules))
|
||||
}
|
||||
|
||||
// The RPZ parser goes through the zone-file reader: CRLF, comments,
|
||||
// directives and blank lines are handled there too.
|
||||
for _, data := range []string{
|
||||
"$TTL 60\r\nexample.com CNAME .\r\n*.sub.example.org CNAME rpz-drop.\r\n",
|
||||
"; comment\r\n\r\nexample.com 60 IN CNAME .\n",
|
||||
"$ORIGIN example.com.\n$TTL 60\nwww IN A 127.0.0.1\n",
|
||||
} {
|
||||
zrules, _, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Errorf("Parse(rpz %q): %v", data, err)
|
||||
continue
|
||||
}
|
||||
for _, r := range zrules {
|
||||
if strings.ContainsAny(r.Name, "\r\n\t ") {
|
||||
t.Errorf("rule name kept whitespace: %q", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A line longer than the scanner's 4 MiB cap is reported, not silently
|
||||
// truncated.
|
||||
_, _, err = Parse(FormatPlain, []byte("a.example.com\n"+hugeDomain+"\n"), ParseOptions{Subdomains: true})
|
||||
if err == nil {
|
||||
t.Errorf("an over-long line parsed without error")
|
||||
} else {
|
||||
t.Logf("over-long line error (expected): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBOMBehaviour(t *testing.T) {
|
||||
// A UTF-8 BOM on the first line is not stripped by the parsers, so the
|
||||
// first rule of the list is lost. Real behaviour, pinned.
|
||||
const bom = "\ufeff"
|
||||
_, stats, err := Parse(FormatAuto, []byte(bom+"0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
t.Logf("BOM sample: format=%s rules=%d malformed=%d notices=%v",
|
||||
stats.Format, stats.Rules, stats.Malformed, stats.Notices)
|
||||
if stats.Rules != 0 || stats.Malformed != 1 {
|
||||
t.Errorf("BOM sample: rules=%d malformed=%d, want 0/1 (current behaviour)",
|
||||
stats.Rules, stats.Malformed)
|
||||
}
|
||||
|
||||
// Without the BOM the same content parses.
|
||||
_, ok, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if ok.Rules != 1 {
|
||||
t.Errorf("control: rules = %d, want 1", ok.Rules)
|
||||
}
|
||||
|
||||
// A BOM on an adblock list behaves the same way: the first rule is lost.
|
||||
abRules, abStats, err := Parse(FormatAdblock, []byte(bom+"||ads.example.com^\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
t.Logf("BOM adblock: rules=%v skipped=%d", ruleStrings(abRules), abStats.Skipped)
|
||||
if abStats.Rules != 0 {
|
||||
t.Errorf("BOM adblock rules = %v, want none (current behaviour)", ruleStrings(abRules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedRulesCompileAndMatch(t *testing.T) {
|
||||
// End to end: an adblock list with a broad root rule plus a narrow
|
||||
// allow rule compiles into a set where the allow wins for the name it
|
||||
// names and the root still covers everything else.
|
||||
data := "||example.com^\n@@||metrics.example.com^\n"
|
||||
rules, _, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 300})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
b := NewBuilder()
|
||||
for _, r := range rules {
|
||||
b.Add(r, "list", []string{"ads"})
|
||||
}
|
||||
s := b.Build()
|
||||
if e := s.Match("ads.example.com"); e == nil || e.Action != ActionBlock {
|
||||
t.Errorf("ads.example.com: %+v, want block", e)
|
||||
}
|
||||
if e := s.Match("example.com"); e == nil || e.Action != ActionBlock {
|
||||
t.Errorf("example.com: %+v, want block (root rule covers the apex)", e)
|
||||
}
|
||||
if e := s.Match("metrics.example.com"); e == nil || !e.IsAllow() {
|
||||
t.Errorf("metrics.example.com: %+v, want allow", e)
|
||||
}
|
||||
if e := s.Match("a.b.metrics.example.com"); e == nil || !e.IsAllow() {
|
||||
t.Errorf("a.b.metrics.example.com: %+v, want allow (the allow rule is a root rule)", e)
|
||||
}
|
||||
for _, r := range rules {
|
||||
if e := s.Lookup(r.Name); e != nil && e.TTL != 300 {
|
||||
t.Errorf("TTL = %d, want 300", e.TTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package blocklist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mustBuild compiles rules into an immutable Set.
|
||||
func mustBuild(rules ...Rule) *Set {
|
||||
b := NewBuilder()
|
||||
for _, r := range rules {
|
||||
b.Add(r, "test", nil)
|
||||
}
|
||||
return b.Build()
|
||||
}
|
||||
|
||||
func block(name string) Rule { return Rule{Name: name, Action: ActionBlock} }
|
||||
|
||||
func TestSetMatch(t *testing.T) {
|
||||
s := mustBuild(
|
||||
block("example.com"), // exact: the name and nothing else
|
||||
block("*.wild.example.com"), // strict subdomains only
|
||||
block("**.root.example.net"), // apex and subdomains
|
||||
block("**.sub.example.org"), // whole subtree
|
||||
block("ads.example.com"), // more specific than the exact "example.com"
|
||||
Rule{Name: "tracker.example.com", Action: ActionAllow}, // exact allow
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
want string // stored rule name, "" = no match
|
||||
kind PatternKind
|
||||
action Action
|
||||
explains string
|
||||
}{
|
||||
{name: "exact apex", query: "example.com", want: "example.com", kind: PatternExact},
|
||||
{name: "exact apex trailing dot", query: "example.com.", want: "example.com", kind: PatternExact},
|
||||
{name: "exact apex uppercase", query: "ExAmPlE.CoM", want: "example.com", kind: PatternExact},
|
||||
{name: "exact rule does not cover subdomains", query: "sub.example.com", want: "", kind: PatternExact},
|
||||
{name: "sub wildcard matches subdomain", query: "a.wild.example.com", want: "wild.example.com", kind: PatternSub},
|
||||
{name: "sub wildcard does not match apex", query: "wild.example.com", want: "", kind: PatternExact},
|
||||
{name: "root wildcard matches apex", query: "root.example.net", want: "root.example.net", kind: PatternRoot},
|
||||
{name: "root wildcard matches subdomain", query: "deep.a.root.example.net", want: "root.example.net", kind: PatternRoot},
|
||||
{name: "subtree", query: "a.b.sub.example.org", want: "sub.example.org", kind: PatternRoot},
|
||||
{name: "subtree apex", query: "sub.example.org", want: "sub.example.org", kind: PatternRoot},
|
||||
{name: "most specific exact wins over root", query: "ads.example.com", want: "ads.example.com", kind: PatternExact},
|
||||
{name: "allow rule reported with its action", query: "tracker.example.com", want: "tracker.example.com", kind: PatternExact, action: ActionAllow},
|
||||
{name: "unrelated name", query: "unrelated.test", want: ""},
|
||||
{name: "empty name", query: "", want: ""},
|
||||
{name: "the root label is normalised away", query: ".", want: ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := s.Match(tc.query)
|
||||
if tc.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("Match(%q) = %q, want no match", tc.query, got.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("Match(%q) = nil, want %q", tc.query, tc.want)
|
||||
}
|
||||
if got.Name != tc.want {
|
||||
t.Errorf("Match(%q) rule = %q, want %q", tc.query, got.Name, tc.want)
|
||||
}
|
||||
wantAction := tc.action
|
||||
if wantAction == ActionBlock && got.Action != ActionAllow {
|
||||
wantAction = ActionBlock
|
||||
}
|
||||
if got.Action != wantAction {
|
||||
t.Errorf("Match(%q) action = %v, want %v", tc.query, got.Action, wantAction)
|
||||
}
|
||||
if k := s.MatchPattern(tc.query); k != tc.kind {
|
||||
t.Errorf("MatchPattern(%q) = %v, want %v", tc.query, k, tc.kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMatchLeadingDotAndSubdomainToggle(t *testing.T) {
|
||||
// splitPattern maps a leading dot to the "**." (root) form.
|
||||
s := mustBuild(block(".dot.example.com"))
|
||||
if e := s.Match("dot.example.com"); e == nil {
|
||||
t.Errorf("leading-dot rule did not match its apex")
|
||||
}
|
||||
if e := s.Match("x.dot.example.com"); e == nil {
|
||||
t.Errorf("leading-dot rule did not match a subdomain")
|
||||
}
|
||||
|
||||
// With Subdomains off the parsers emit exact rules, so nothing below the
|
||||
// apex is covered.
|
||||
exact := mustBuild(hostsRule("ads.example.com", ParseOptions{Subdomains: false}))
|
||||
if e := exact.Match("sub.ads.example.com"); e != nil {
|
||||
t.Errorf("exact hosts rule matched a subdomain: %q", e.Name)
|
||||
}
|
||||
if e := exact.Match("ads.example.com"); e == nil {
|
||||
t.Errorf("exact hosts rule did not match its own name")
|
||||
}
|
||||
if exact.HasWildcards() {
|
||||
t.Errorf("HasWildcards() = true for an exact-only set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropsUnusablePatterns(t *testing.T) {
|
||||
// "." is "the whole internet" and is never useful: it is dropped. A bare
|
||||
// TLD, on the other hand, is kept and matches the TLD itself.
|
||||
s := mustBuild(block("."), block("*.com"), block("com"), block(""), block(" "))
|
||||
if s.Len() != 2 {
|
||||
t.Fatalf("Len() = %d, want 2 (exact \"com\" and sub \"com\")", s.Len())
|
||||
}
|
||||
if e := s.Match("com"); e == nil || e.Name != "com" {
|
||||
t.Errorf("Match(\"com\") = %v, want the exact rule \"com\"", e)
|
||||
}
|
||||
if e := s.Match("ads.com"); e == nil || e.Name != "com" {
|
||||
t.Errorf("Match(\"ads.com\") = %v, want sub rule \"com\"", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMatchesWholeSubtree(t *testing.T) {
|
||||
// A single root rule on a TLD covers its whole subtree.
|
||||
s := mustBuild(block("**.com"))
|
||||
for _, q := range []string{"com", "example.com", "a.b.c.example.com", "EXAMPLE.COM."} {
|
||||
if e := s.Match(q); e == nil || e.Name != "com" {
|
||||
t.Errorf("Match(%q) = %v, want the \"**.com\" rule", q, e)
|
||||
}
|
||||
}
|
||||
for _, q := range []string{"de", "example.de", "com.evil.test"} {
|
||||
if e := s.Match(q); e != nil {
|
||||
t.Errorf("Match(%q) = %q, want no match", q, e.Name)
|
||||
}
|
||||
}
|
||||
// The apex is covered by "**." but not by "*.".
|
||||
sub := mustBuild(block("*.com"))
|
||||
if e := sub.Match("com"); e != nil {
|
||||
t.Errorf("\"*.com\" matched the apex: %q", e.Name)
|
||||
}
|
||||
if e := sub.Match("example.com"); e == nil {
|
||||
t.Errorf("\"*.com\" did not match a subdomain")
|
||||
}
|
||||
// A single label or an empty name never reaches any map.
|
||||
if e := s.Match(""); e != nil {
|
||||
t.Errorf("Match(\"\") = %q, want nil", e.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"example.com", "example.com"},
|
||||
{"example.com.", "example.com"},
|
||||
{"EXAMPLE.com..", "example.com."}, // one trailing dot, then lowercased
|
||||
{"", ""},
|
||||
{".", ""},
|
||||
{"..", "."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := NormalizeName(tc.in); got != tc.want {
|
||||
t.Errorf("NormalizeName(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
// Match must treat "example.com." and "example.com" as the same name,
|
||||
// and must be case-insensitive.
|
||||
s := mustBuild(block("example.com"))
|
||||
if s.Match("example.com.") == nil || s.Match("EXAMPLE.COM") == nil {
|
||||
t.Errorf("Match is not case/trailing-dot insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowOverBlockPriority(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
b.Add(Rule{Name: "cdn.example.com", Action: ActionBlock}, "blocklist", nil)
|
||||
b.Add(Rule{Name: "cdn.example.com", Action: ActionAllow}, "allowlist", []string{"user"})
|
||||
s := b.Build()
|
||||
|
||||
e := s.Match("cdn.example.com")
|
||||
if e == nil {
|
||||
t.Fatal("no rule matched")
|
||||
}
|
||||
if !e.IsAllow() {
|
||||
t.Errorf("IsAllow() = false for %v", e.Action)
|
||||
}
|
||||
if e.Action != ActionAllow {
|
||||
t.Errorf("action = %v, want allow", e.Action)
|
||||
}
|
||||
if e.Source != "allowlist" {
|
||||
t.Errorf("Source = %q, want %q (the winning rule's list)", e.Source, "allowlist")
|
||||
}
|
||||
if e.Priority != 10 {
|
||||
t.Errorf("Priority = %d, want 10 (allow default)", e.Priority)
|
||||
}
|
||||
if got := e.Categories; len(got) != 1 || got[0] != "user" {
|
||||
t.Errorf("Categories = %v, want [user]", got)
|
||||
}
|
||||
c := s.Counts()
|
||||
if c.Allow != 1 || c.Block != 0 {
|
||||
t.Errorf("Counts() = %+v, want Allow=1 Block=0 (block rule was replaced)", c)
|
||||
}
|
||||
if s.Duplicates() != 1 {
|
||||
t.Errorf("Duplicates() = %d, want 1", s.Duplicates())
|
||||
}
|
||||
if s.Lookup("cdn.example.com").Action != ActionAllow {
|
||||
t.Errorf("Lookup() disagrees with Match()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityTieKeepsFirstRule(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "first", nil)
|
||||
b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "second", nil)
|
||||
s := b.Build()
|
||||
if s.Lookup("tie.example.com").Source != "first" {
|
||||
t.Errorf("on a priority tie the later rule won: source = %q", s.Lookup("tie.example.com").Source)
|
||||
}
|
||||
if s.Duplicates() != 1 {
|
||||
t.Errorf("Duplicates() = %d, want 1", s.Duplicates())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthroughCountsAsAllow(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
b.Add(Rule{Name: "pt.example.com", Action: ActionPassthrough}, "rpz", nil)
|
||||
s := b.Build()
|
||||
e := s.Match("pt.example.com")
|
||||
if e == nil || !e.IsAllow() {
|
||||
t.Fatalf("passthrough rule: IsAllow() = false, e = %v", e)
|
||||
}
|
||||
if c := s.Counts(); c.Passthru != 1 || c.Allow != 0 {
|
||||
t.Errorf("Counts() = %+v, want Passthru=1", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHits(t *testing.T) {
|
||||
s := mustBuild(block("hit.example.com"), block("*.wild.hit.example.com"))
|
||||
const n = 3
|
||||
for i := 0; i < n; i++ {
|
||||
if s.Match("hit.example.com") == nil {
|
||||
t.Fatal("setup: no match")
|
||||
}
|
||||
}
|
||||
e := s.Match("hit.example.com")
|
||||
if got := e.Hits(); got != n+1 {
|
||||
t.Errorf("Hits() = %d, want %d (every Match counts, including this one)", got, n+1)
|
||||
}
|
||||
// Non-matching queries must not bump any counter.
|
||||
s.Match("miss.example.com")
|
||||
if got := s.Match("hit.example.com").Hits(); got != n+2 {
|
||||
t.Errorf("Hits() = %d after a miss, want %d", got, n+2)
|
||||
}
|
||||
// A wildcard rule has its own counter, and the shared *Entry is visible
|
||||
// through every pointer to it.
|
||||
if got := s.Match("a.wild.hit.example.com").Hits(); got != 1 {
|
||||
t.Errorf("wildcard Hits() = %d, want 1", got)
|
||||
}
|
||||
if got := s.Match("hit.example.com").Hits(); got != n+3 {
|
||||
t.Errorf("exact Hits() = %d, want %d", got, n+3)
|
||||
}
|
||||
// A zero-valued Entry is safe.
|
||||
var zero Entry
|
||||
if zero.Hits() != 0 {
|
||||
t.Errorf("zero Entry Hits() = %d, want 0", zero.Hits())
|
||||
}
|
||||
if got := (*Entry)(nil).Hits(); got != 0 {
|
||||
t.Errorf("nil Entry Hits() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateDomainsAcrossLists(t *testing.T) {
|
||||
// The same domain from two lists compiles to ONE rule: the dedup happens
|
||||
// in Builder.Add, keyed on the normalised pattern, and the losing rule is
|
||||
// dropped (Duplicates counts it) rather than kept as a second entry.
|
||||
b := NewBuilder()
|
||||
b.Add(Rule{Name: "Dup.Example.com.", Action: ActionBlock}, "list-a", nil)
|
||||
b.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-b", nil)
|
||||
s := b.Build()
|
||||
|
||||
if s.Len() != 1 {
|
||||
t.Fatalf("Len() = %d, want 1 (duplicates are deduped)", s.Len())
|
||||
}
|
||||
if s.Duplicates() != 1 {
|
||||
t.Errorf("Duplicates() = %d, want 1", s.Duplicates())
|
||||
}
|
||||
e := s.Match("dup.example.com")
|
||||
if e == nil {
|
||||
t.Fatal("no match")
|
||||
}
|
||||
if e.Source != "list-a" {
|
||||
t.Errorf("Source = %q, want list-a (first list wins on a tie)", e.Source)
|
||||
}
|
||||
// One Entry => one hit counter, regardless of how many lists listed it.
|
||||
if got := e.Hits(); got != 1 {
|
||||
t.Errorf("Hits() = %d, want 1 (deduped rules share one counter)", got)
|
||||
}
|
||||
// A higher-priority rule from a later list replaces the earlier one.
|
||||
b2 := NewBuilder()
|
||||
b2.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-a", nil)
|
||||
b2.Add(Rule{Name: "dup.example.com", Action: ActionAllow}, "list-b", nil)
|
||||
s2 := b2.Build()
|
||||
if a := s2.Match("dup.example.com"); a == nil || a.Source != "list-b" {
|
||||
t.Errorf("higher-priority later rule did not win: %+v", a)
|
||||
}
|
||||
if s2.Duplicates() != 1 {
|
||||
t.Errorf("Duplicates() = %d, want 1", s2.Duplicates())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetIntrospection(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
b.Add(block("exact.example.com"), "a", nil)
|
||||
b.Add(block("*.sub.example.com"), "b", nil)
|
||||
b.Add(block("**.root.example.com"), "c", nil)
|
||||
b.NoteSource(SourceMeta{Name: "a", Format: "hosts", Rules: 1, Bytes: 42, Enabled: true})
|
||||
s := b.Build()
|
||||
|
||||
if got := s.Sources(); len(got) != 3 || got[0] != "a" || got[2] != "c" {
|
||||
t.Errorf("Sources() = %v, want insertion order [a b c]", got)
|
||||
}
|
||||
m, ok := s.SourceMeta("b")
|
||||
if !ok {
|
||||
t.Errorf("SourceMeta(\"b\") missing")
|
||||
}
|
||||
_ = m
|
||||
if a, ok := s.SourceMeta("a"); !ok || a.Rules != 1 || a.Bytes != 42 || a.Format != "hosts" {
|
||||
t.Errorf("SourceMeta(\"a\") = %+v, ok=%v", a, ok)
|
||||
}
|
||||
if _, ok := s.SourceMeta("nope"); ok {
|
||||
t.Errorf("SourceMeta(\"nope\") reported ok")
|
||||
}
|
||||
if c := s.Counts(); c.Exact != 1 || c.Sub != 1 || c.Root != 1 || c.Block != 3 {
|
||||
t.Errorf("Counts() = %+v, want 1/1/1 exact/sub/root and 3 block", c)
|
||||
}
|
||||
st := s.Stats()
|
||||
if st.Total != 3 || st.Exact != 1 || st.Subdomains != 1 || st.RootRules != 1 {
|
||||
t.Errorf("Stats() = %+v", st)
|
||||
}
|
||||
if st.BuiltAt.IsZero() {
|
||||
t.Errorf("Stats().BuiltAt is zero")
|
||||
}
|
||||
|
||||
// Each renders patterns back with their wildcard prefix.
|
||||
seen := map[string]PatternKind{}
|
||||
s.Each(func(pattern string, e *Entry, kind PatternKind) bool {
|
||||
seen[pattern] = kind
|
||||
if got := kind.Pattern(e.Name); got != pattern {
|
||||
t.Errorf("Pattern(%q) = %q, want %q", e.Name, got, pattern)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if seen["exact.example.com"] != PatternExact || seen["sub.example.com"] != PatternSub ||
|
||||
seen["root.example.com"] != PatternRoot {
|
||||
t.Errorf("Each() visited %v", seen)
|
||||
}
|
||||
if PatternSub.String() != "subdomain" || PatternRoot.String() != "domain" || PatternExact.String() != "exact" {
|
||||
t.Errorf("PatternKind.String() values changed")
|
||||
}
|
||||
|
||||
// Lookup is exact-only: it does not walk wildcards.
|
||||
if s.Lookup("deep.sub.example.com") != nil {
|
||||
t.Errorf("Lookup() walked wildcards")
|
||||
}
|
||||
if s.Lookup("EXACT.example.com.") == nil {
|
||||
t.Errorf("Lookup() is not normalised")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestStableAndContentSensitive(t *testing.T) {
|
||||
a := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.z.example.com"))
|
||||
b := mustBuild(block("**.z.example.com"), block("x.example.com"), block("*.y.example.com"))
|
||||
if a.Digest() != b.Digest() {
|
||||
t.Errorf("Digest() depends on insertion order: %q vs %q", a.Digest(), b.Digest())
|
||||
}
|
||||
c := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.different.example.com"))
|
||||
if a.Digest() == c.Digest() {
|
||||
t.Errorf("Digest() ignored a pattern change")
|
||||
}
|
||||
// The action is part of the digest.
|
||||
d := mustBuild(Rule{Name: "x.example.com", Action: ActionAllow})
|
||||
if a.Digest() == d.Digest() {
|
||||
t.Errorf("Digest() ignored the action change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilSetIsSafe(t *testing.T) {
|
||||
var s *Set
|
||||
if s.Match("example.com") != nil || s.Len() != 0 || s.HasWildcards() ||
|
||||
s.DuplicatesN() != 0 || s.Sources() != nil || s.AllSourceMeta() != nil ||
|
||||
s.Digest() != "" || !s.BuiltAt().IsZero() {
|
||||
t.Errorf("nil *Set accessors are not nil-safe")
|
||||
}
|
||||
if _, ok := s.SourceMeta("x"); ok {
|
||||
t.Errorf("nil Set SourceMeta reported ok")
|
||||
}
|
||||
if s.Lookup("x") != nil || s.MatchPattern("x") != PatternExact {
|
||||
t.Errorf("nil Set lookup misbehaved")
|
||||
}
|
||||
if st := s.Stats(); st.Total != 0 || st.PerSource == nil {
|
||||
t.Errorf("nil Stats() = %+v", st)
|
||||
}
|
||||
s.Each(func(string, *Entry, PatternKind) bool { t.Errorf("nil Each called fn"); return true })
|
||||
}
|
||||
|
||||
func TestActionStringAndParseAction(t *testing.T) {
|
||||
all := []struct {
|
||||
a Action
|
||||
str string
|
||||
}{
|
||||
{ActionBlock, "block"},
|
||||
{ActionAllow, "allow"},
|
||||
{ActionPassthrough, "passthrough"},
|
||||
{ActionDrop, "drop"},
|
||||
{ActionNXDomain, "nxdomain"},
|
||||
{ActionZero, "zero"},
|
||||
{ActionCNAME, "cname"},
|
||||
{ActionRedirect, "redirect"},
|
||||
{Action(99), "unknown"},
|
||||
}
|
||||
for _, tc := range all {
|
||||
if got := tc.a.String(); got != tc.str {
|
||||
t.Errorf("Action(%d).String() = %q, want %q", tc.a, got, tc.str)
|
||||
}
|
||||
}
|
||||
roundtrip := map[string]Action{
|
||||
"": ActionBlock,
|
||||
"block": ActionBlock,
|
||||
"ALLOW": ActionAllow,
|
||||
" whitelist ": ActionAllow,
|
||||
"pass": ActionPassthrough,
|
||||
"drop": ActionDrop,
|
||||
"nxdomain": ActionNXDomain,
|
||||
"null": ActionZero,
|
||||
"cname": ActionCNAME,
|
||||
"redirect": ActionRedirect,
|
||||
}
|
||||
for in, want := range roundtrip {
|
||||
got, ok := ParseAction(in)
|
||||
if !ok || got != want {
|
||||
t.Errorf("ParseAction(%q) = %v,%v; want %v,true", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
if got, ok := ParseAction("nonsense"); ok || got != ActionBlock {
|
||||
t.Errorf("ParseAction(\"nonsense\") = %v,%v; want block,false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolderSwapIsAtomic(t *testing.T) {
|
||||
old := mustBuild(block("a.example.com"))
|
||||
newer := mustBuild(block("b.example.com"))
|
||||
h := NewHolder(old)
|
||||
if h.Get() != old {
|
||||
t.Fatalf("NewHolder did not install the set")
|
||||
}
|
||||
var empty *Holder
|
||||
if empty.Get() != nil {
|
||||
t.Errorf("zero Holder is not empty")
|
||||
}
|
||||
// A reader either sees the whole old set or the whole new one.
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
s := h.Get()
|
||||
if s == nil {
|
||||
t.Error("holder returned nil")
|
||||
return
|
||||
}
|
||||
if e := s.Match("a.example.com"); e == nil {
|
||||
t.Error("observed a half-applied set")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
h.Set(newer)
|
||||
h.Set(old)
|
||||
}
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
if h.Get() != old {
|
||||
t.Errorf("holder lost the last swap")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSetMatch(b *testing.B) {
|
||||
// A mixed set, like a compiled blocklist: mostly exact rules plus some
|
||||
// subdomain and root rules so the suffix walk is exercised.
|
||||
bld := NewBuilder()
|
||||
for i := 0; i < 2000; i++ {
|
||||
bld.Add(block(fmt.Sprintf("host%d.ads-%d.example.com", i, i%7)), "bench", nil)
|
||||
}
|
||||
for i := 0; i < 200; i++ {
|
||||
bld.Add(block(fmt.Sprintf("*.wild%d.example.net", i)), "bench", nil)
|
||||
bld.Add(block(fmt.Sprintf("**.root%d.example.org", i)), "bench", nil)
|
||||
}
|
||||
s := bld.Build()
|
||||
|
||||
queries := []string{
|
||||
"host0.ads-0.example.com", // exact hit
|
||||
"host1999.ads-5.example.com", // exact hit, different list
|
||||
"a.b.wild199.example.net", // wildcard suffix hit
|
||||
"x.root42.example.org", // root rule
|
||||
"nothing.in.here.test", // pure miss, walks every suffix
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
var sink int
|
||||
for i := 0; i < b.N; i++ {
|
||||
if e := s.Match(queries[i%len(queries)]); e != nil {
|
||||
sink++
|
||||
}
|
||||
}
|
||||
if sink == 0 {
|
||||
b.Fatal("no matches at all")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user