From 70a366c933e005e91c0078940caff7f004903cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andri=20=C3=93skarsson?= Date: Wed, 25 Feb 2026 20:47:46 +0100 Subject: [PATCH] Index rules by domain for fast lookup in ShouldBlockRequest Partition domain-anchored rules (||domain...) into map[string][]*Rule keyed by domain suffix. At match time, walk up the hostname hierarchy and only check rules for matching domains instead of scanning all rules. Same approach for exceptions. Pre-lowercase URL and context once per request to eliminate redundant strings.ToLower calls in each rule match. --- pkg/blocklist/pattern.go | 51 +++++++++++++++++----- pkg/blocklist/ruleset.go | 91 ++++++++++++++++++++++++++++++---------- 2 files changed, 109 insertions(+), 33 deletions(-) diff --git a/pkg/blocklist/pattern.go b/pkg/blocklist/pattern.go index 2f85bb4..26319a3 100644 --- a/pkg/blocklist/pattern.go +++ b/pkg/blocklist/pattern.go @@ -179,7 +179,12 @@ func (r *Rule) Match(rawURL string) bool { if !r.options.MatchCase { url = strings.ToLower(rawURL) } + return r.matchURL(url) +} +// matchURL matches the pre-cased URL against the rule's pattern. +// Callers must ensure url is already lowercased for case-insensitive rules. +func (r *Rule) matchURL(url string) bool { if r.domainAnchor { return r.matchDomainAnchor(url) } @@ -206,6 +211,27 @@ func (r *Rule) MatchWithContext(rawURL string, ctx MatchContext) bool { return r.checkOptions(rawURL, ctx) } +// matchWithContextLower matches using a pre-lowercased URL to avoid redundant +// ToLower calls when checking many rules against the same URL. +func (r *Rule) matchWithContextLower(lowerURL string, ctx MatchContext) bool { + url := lowerURL + if r.options.MatchCase { + // match-case rules need the original URL, but we only have the lowered + // version — fall back to re-checking. This is rare in practice. + return false + } + if !r.matchURL(url) { + return false + } + return r.checkOptionsLower(lowerURL, ctx) +} + +// DomainAnchor returns true if the rule uses a || domain anchor. +func (r *Rule) DomainAnchor() bool { return r.domainAnchor } + +// DomainSuffix returns the domain part of a || domain-anchored rule (e.g. "example.com"). +func (r *Rule) DomainSuffix() string { return r.domainSuffix } + // HasContextOptions returns true if the rule has options that require // a MatchContext to evaluate (e.g. $third-party, $domain). func (r *Rule) HasContextOptions() bool { @@ -215,10 +241,17 @@ func (r *Rule) HasContextOptions() bool { } func (r *Rule) checkOptions(rawURL string, ctx MatchContext) bool { + lowerURL := strings.ToLower(rawURL) + lowerCtx := MatchContext{PageDomain: strings.ToLower(ctx.PageDomain)} + return r.checkOptionsLower(lowerURL, lowerCtx) +} + +// checkOptionsLower evaluates context-dependent options using pre-lowercased +// URL and context to avoid redundant ToLower calls in hot paths. +func (r *Rule) checkOptionsLower(lowerURL string, ctx MatchContext) bool { if r.options.ThirdParty != nil { - requestDomain := extractHostFromURL(strings.ToLower(rawURL)) - pageDomain := strings.ToLower(ctx.PageDomain) - isThirdParty := !domainMatchesOrIsSubdomain(requestDomain, pageDomain) + requestDomain := extractHostFromURL(lowerURL) + isThirdParty := !domainMatchesOrIsSubdomain(requestDomain, ctx.PageDomain) if *r.options.ThirdParty && !isThirdParty { return false @@ -229,10 +262,9 @@ func (r *Rule) checkOptions(rawURL string, ctx MatchContext) bool { } if len(r.options.IncludeDomains) > 0 { - pageDomain := strings.ToLower(ctx.PageDomain) matched := false for _, d := range r.options.IncludeDomains { - if domainMatchesOrIsSubdomain(pageDomain, d) { + if domainMatchesOrIsSubdomain(ctx.PageDomain, d) { matched = true break } @@ -242,12 +274,9 @@ func (r *Rule) checkOptions(rawURL string, ctx MatchContext) bool { } } - if len(r.options.ExcludeDomains) > 0 { - pageDomain := strings.ToLower(ctx.PageDomain) - for _, d := range r.options.ExcludeDomains { - if domainMatchesOrIsSubdomain(pageDomain, d) { - return false - } + for _, d := range r.options.ExcludeDomains { + if domainMatchesOrIsSubdomain(ctx.PageDomain, d) { + return false } } diff --git a/pkg/blocklist/ruleset.go b/pkg/blocklist/ruleset.go index 3d622a1..127384d 100644 --- a/pkg/blocklist/ruleset.go +++ b/pkg/blocklist/ruleset.go @@ -13,8 +13,10 @@ import ( // Element hiding rules (##) provide CSS selectors to hide page elements. type RuleSet struct { hosts map[string]struct{} - rules []*Rule - exceptions []*Rule + rules []*Rule // generic rules (no domain anchor) + domainRules map[string][]*Rule // domain-anchored rules keyed by domain suffix + exceptions []*Rule // generic exceptions + domainExc map[string][]*Rule // domain-anchored exceptions keyed by domain suffix elemHideRules []*ElementHideRule elemHideIdx *elemHideIndex } @@ -22,6 +24,8 @@ type RuleSet struct { func NewRuleSet() *RuleSet { return &RuleSet{ hosts: make(map[string]struct{}), + domainRules: make(map[string][]*Rule), + domainExc: make(map[string][]*Rule), elemHideIdx: newElemHideIndex(), } } @@ -33,26 +37,37 @@ func (rs *RuleSet) AddHostname(host string) { } // 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. +// prefix) and adds it to the exception list. Domain-anchored exceptions are +// indexed by domain suffix for fast lookup; all others go into the generic list. 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) + if rule.DomainAnchor() && rule.DomainSuffix() != "" { + key := rule.DomainSuffix() + rs.domainExc[key] = append(rs.domainExc[key], rule) + } else { + 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. +// Domain-anchored rules (||domain...) are indexed by domain suffix for fast +// lookup; all other rules go into the generic list. func (rs *RuleSet) AddRule(pattern string) error { rule, err := Compile(pattern) if err != nil { return err } - rs.rules = append(rs.rules, rule) + if rule.DomainAnchor() && rule.DomainSuffix() != "" { + key := rule.DomainSuffix() + rs.domainRules[key] = append(rs.domainRules[key], rule) + } else { + rs.rules = append(rs.rules, rule) + } return nil } @@ -107,7 +122,7 @@ func (rs *RuleSet) AddLine(line string) { return } - // Strip options after $ for now (Phase 3 will handle them) + // Strip options for hostname extraction checks (Compile handles options itself) rawPattern := line if idx := strings.IndexByte(rawPattern, '$'); idx >= 0 { rawPattern = rawPattern[:idx] @@ -130,12 +145,8 @@ func (rs *RuleSet) AddLine(line string) { return } - // Compile as a URL pattern rule - rule, err := Compile(rawPattern) - if err != nil { - return // skip invalid patterns silently - } - rs.rules = append(rs.rules, rule) + // Compile as a URL pattern rule (routed to domain index or generic list) + rs.AddRule(line) } // extractHostnameRule checks if the pattern is a hostname-only rule @@ -181,19 +192,27 @@ func (rs *RuleSet) ShouldBlockRequest(rawURL string, ctx MatchContext) bool { return false } + // Pre-lowercase once to avoid redundant ToLower in each rule match + lowerURL := strings.ToLower(rawURL) + lowerCtx := MatchContext{PageDomain: strings.ToLower(ctx.PageDomain)} + host := extractHostFromURL(lowerURL) + blocked := false // Fast path: check hostname against the hostname map - url := strings.ToLower(rawURL) - host := extractHostFromURL(url) if rs.isHostBlocked(host) { blocked = true } - // Slow path: check URL against compiled pattern rules + // Check domain-indexed rules by walking up the hostname hierarchy + if !blocked { + blocked = rs.matchDomainIndexed(rs.domainRules, host, lowerURL, lowerCtx) + } + + // Fall through to generic rules (non-domain-anchored) if !blocked { for _, rule := range rs.rules { - if rule.MatchWithContext(rawURL, ctx) { + if rule.matchWithContextLower(lowerURL, lowerCtx) { blocked = true break } @@ -204,9 +223,14 @@ func (rs *RuleSet) ShouldBlockRequest(rawURL string, ctx MatchContext) bool { return false } - // Check if any exception rule allows this URL + // Check domain-indexed exceptions + if rs.matchDomainIndexed(rs.domainExc, host, lowerURL, lowerCtx) { + return false + } + + // Check generic exceptions for _, exc := range rs.exceptions { - if exc.MatchWithContext(rawURL, ctx) { + if exc.matchWithContextLower(lowerURL, lowerCtx) { return false } } @@ -214,6 +238,24 @@ func (rs *RuleSet) ShouldBlockRequest(rawURL string, ctx MatchContext) bool { return true } +// matchDomainIndexed walks up the hostname hierarchy and checks rules in the +// domain-indexed map. Returns true if any rule matches the URL. +func (rs *RuleSet) matchDomainIndexed(index map[string][]*Rule, host, lowerURL string, ctx MatchContext) bool { + h := host + for { + for _, rule := range index[h] { + if rule.matchWithContextLower(lowerURL, ctx) { + return true + } + } + dot := strings.IndexByte(h, '.') + if dot < 0 { + return false + } + h = h[dot+1:] + } +} + // IsHostBlocked returns true if the hostname (or any parent domain) is in // the fast-path hostname map. Safe to call on a nil receiver. // Use this for CONNECT-level blocking where only the hostname is available. @@ -264,10 +306,15 @@ func (rs *RuleSet) HostCount() int { return len(rs.hosts) } -// RuleCount returns the number of compiled URL pattern rules. +// RuleCount returns the number of compiled URL pattern rules +// (both domain-indexed and generic). func (rs *RuleSet) RuleCount() int { if rs == nil { return 0 } - return len(rs.rules) + n := len(rs.rules) + for _, rules := range rs.domainRules { + n += len(rules) + } + return n } -- 2.51.2