diff --git a/pkg/blocklist/ruleset.go b/pkg/blocklist/ruleset.go index 29e2023..6b8083f 100644 --- a/pkg/blocklist/ruleset.go +++ b/pkg/blocklist/ruleset.go @@ -9,9 +9,11 @@ import ( // RuleSet holds blocking rules for URL filtering. It combines a hostname map // (fast path for ||hostname^ rules) with compiled URL pattern rules. +// Exception rules (@@) override blocking rules when they match. type RuleSet struct { - hosts map[string]struct{} - rules []*Rule + hosts map[string]struct{} + rules []*Rule + exceptions []*Rule } func NewRuleSet() *RuleSet { @@ -24,6 +26,19 @@ func (rs *RuleSet) AddHostname(host string) { rs.hosts[strings.ToLower(host)] = struct{}{} } +// AddException compiles an adblock exception pattern (with or without @@ +// prefix) and adds it to the exception list. Exception rules override +// blocking rules when they match a URL. +func (rs *RuleSet) AddException(pattern string) error { + pattern = strings.TrimPrefix(pattern, "@@") + rule, err := Compile(pattern) + if err != nil { + return err + } + rs.exceptions = append(rs.exceptions, rule) + return nil +} + // AddRule compiles an adblock URL pattern and adds it to the rule list. // Returns an error if the pattern is invalid. func (rs *RuleSet) AddRule(pattern string) error { @@ -60,8 +75,9 @@ func (rs *RuleSet) addLine(line string) { return } - // Exception rules (Phase 2) + // Exception rules if strings.HasPrefix(line, "@@") { + rs.AddException(line) return } @@ -129,28 +145,44 @@ func extractHostnameRule(pattern string) (string, bool) { return host, true } -// ShouldBlock returns true if the URL matches any blocking rule. -// Safe to call on a nil receiver (returns false). +// ShouldBlock returns true if the URL matches any blocking rule and no +// exception rule overrides it. Safe to call on a nil receiver (returns false). func (rs *RuleSet) ShouldBlock(rawURL string) bool { if rs == nil { return false } + blocked := false + // Fast path: check hostname against the hostname map url := strings.ToLower(rawURL) host := extractHostFromURL(url) if rs.isHostBlocked(host) { - return true + blocked = true } // Slow path: check URL against compiled pattern rules - for _, rule := range rs.rules { - if rule.Match(rawURL) { - return true + if !blocked { + for _, rule := range rs.rules { + if rule.Match(rawURL) { + blocked = true + break + } + } + } + + if !blocked { + return false + } + + // Check if any exception rule allows this URL + for _, exc := range rs.exceptions { + if exc.Match(rawURL) { + return false } } - return false + return true } // IsHostBlocked returns true if the hostname (or any parent domain) is in diff --git a/pkg/blocklist/ruleset_test.go b/pkg/blocklist/ruleset_test.go index 0064a6a..406c568 100644 --- a/pkg/blocklist/ruleset_test.go +++ b/pkg/blocklist/ruleset_test.go @@ -178,3 +178,95 @@ func TestRuleSetConnectBlocking(t *testing.T) { t.Error("IsHostBlocked should return false for non-blocked hostname") } } + +func TestRuleSetExceptionOverridesBlock(t *testing.T) { + rs := blocklist.NewRuleSet() + rs.AddHostname("ads.example.com") + rs.AddException("@@||ads.example.com/safe-page^") + + // The exception should allow this specific path + if rs.ShouldBlock("http://ads.example.com/safe-page") { + t.Error("exception should allow /safe-page") + } + + // Other paths on the blocked domain should still be blocked + if !rs.ShouldBlock("http://ads.example.com/tracking.js") { + t.Error("non-excepted path should still be blocked") + } +} + +func TestRuleSetExceptionOverridesPatternBlock(t *testing.T) { + rs := blocklist.NewRuleSet() + rs.AddRule("/ads/*") + rs.AddException("@@/ads/acceptable*") + + // Exception allows URLs matching the exception pattern + if rs.ShouldBlock("http://example.com/ads/acceptable-banner.gif") { + t.Error("exception should allow acceptable ads") + } + + // Non-excepted URLs still blocked + if !rs.ShouldBlock("http://example.com/ads/tracking.js") { + t.Error("non-excepted ads URL should still be blocked") + } +} + +func TestRuleSetExceptionHostname(t *testing.T) { + rs := blocklist.NewRuleSet() + rs.AddHostname("ads.example.com") + rs.AddException("@@||ads.example.com^") + + // Full hostname exception should allow everything on that domain + if rs.ShouldBlock("http://ads.example.com/anything") { + t.Error("hostname exception should allow all paths") + } + if rs.ShouldBlock("http://ads.example.com/tracking.js") { + t.Error("hostname exception should allow all paths") + } +} + +func TestRuleSetLoadFileWithExceptions(t *testing.T) { + content := `||ads.example.com^ +/tracking.js +@@||ads.example.com/approved^ +@@/tracking.js?partner=trusted +` + f, err := os.CreateTemp("", "exception-test-*.txt") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + + if _, err := f.WriteString(content); err != nil { + t.Fatal(err) + } + f.Close() + + rs := blocklist.NewRuleSet() + if err := rs.LoadFile(f.Name()); err != nil { + t.Fatalf("LoadFile: %v", err) + } + + tests := []struct { + url string + want bool + }{ + // Blocked by hostname + {"http://ads.example.com/banner.gif", true}, + // Exception allows this specific path + {"http://ads.example.com/approved", false}, + // Blocked by URL pattern + {"http://other.com/tracking.js", true}, + // Exception allows this specific query + {"http://other.com/tracking.js?partner=trusted", false}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + got := rs.ShouldBlock(tt.url) + if got != tt.want { + t.Errorf("ShouldBlock(%q) = %v, want %v", tt.url, got, tt.want) + } + }) + } +} diff --git a/proxy_test.go b/proxy_test.go index 1fc7243..60e98cd 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -497,3 +497,49 @@ func TestBlocksHTTPSByURLPattern(t *testing.T) { t.Errorf("upstream saw paths = %v, want [/page.html]", requestPaths) } } + +func TestExceptionAllowsBlockedHTTPPath(t *testing.T) { + var requestPaths []string + + rs := blocklist.NewRuleSet() + rs.AddRule("/ads/*") + rs.AddException("@@/ads/approved*") + + env := startTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPaths = append(requestPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }), rs) + + client := env.httpClient(t) + + // Blocked by /ads/* rule + resp, err := client.Get(env.httpURL + "/ads/tracking.js") + if err != nil { + t.Fatalf("GET blocked URL: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("blocked status = %d, want %d", resp.StatusCode, http.StatusNoContent) + } + + // Exception allows /ads/approved* + resp, err = client.Get(env.httpURL + "/ads/approved-banner.gif") + if err != nil { + t.Fatalf("GET excepted URL: %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("excepted status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if string(body) != "ok" { + t.Errorf("excepted body = %q, want %q", body, "ok") + } + + // Only the excepted request should have reached upstream + if len(requestPaths) != 1 || requestPaths[0] != "/ads/approved-banner.gif" { + t.Errorf("upstream saw paths = %v, want [/ads/approved-banner.gif]", requestPaths) + } +}