From 52e72736ade1de23616193d94a1c925a813b49e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Jul 2026 11:09:48 +0800 Subject: [PATCH] route: simplify rule_set matching semantics Since b0c6762bc, every rule inside a referenced rule-set was evaluated as if merged into the outer rule, which required tracking per-branch group states and let outer rules and rule-set rules satisfy each other's grouped conditions in both directions. Restrict merging to the only designed case: a rule-set containing exactly one non-inverted default rule is merged into the outer rule as before. Any other rule-set now matches as an ordinary condition of the outer rule: it matches when any of its rules matches on its own, and its rules no longer exchange grouped match state with the outer rule in either direction. Multiple referenced rule-sets keep OR semantics. Flat address rule-sets such as generated geosite/geoip sets contain a single default rule, so their behavior is unchanged. The group-state set machinery is replaced by a single required/satisfied mask pair. Also update the route and DNS rule docs. --- docs/configuration/dns/rule.md | 2 +- docs/configuration/dns/rule.zh.md | 4 +- docs/configuration/route/rule.md | 2 +- docs/configuration/route/rule.zh.md | 4 +- route/rule/match_state.go | 110 +--------- route/rule/rule_abstract.go | 178 ++++++---------- route/rule/rule_default.go | 8 - route/rule/rule_dns.go | 73 +++---- route/rule/rule_headless.go | 8 - route/rule/rule_item_rule_set.go | 71 +++++-- route/rule/rule_set_local.go | 16 +- route/rule/rule_set_remote.go | 16 +- route/rule/rule_set_semantics_test.go | 291 ++++++++++++++++++++++++-- 13 files changed, 445 insertions(+), 338 deletions(-) diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 6e136c6cb..9a29cedf5 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -242,7 +242,7 @@ icon: material/alert-decagram (`source_port` || `source_port_range`) && `other fields` - Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + When a rule-set contains only a single default rule without `invert`, its fields are considered merged into the outer rule per the logic above; otherwise, it is matched as an `other field`; different rule-sets always keep OR semantics. #### inbound diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index ad56ca41c..9f07ca74f 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -240,9 +240,9 @@ icon: material/alert-decagram (`port` || `port_range`) && (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && (`source_port` || `source_port_range`) && - `other fields` + `其他字段` - 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + 当规则集仅包含一条默认规则且非 invert 时,其中字段视为按以上规则与外层规则合并;否则,作为一条 `其他字段` 匹配;不同规则集之间始终保持 or。 #### inbound diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 6d95c9312..5e9909296 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -214,7 +214,7 @@ icon: material/new-box (`source_port` || `source_port_range`) && `other fields` - Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + When a rule-set contains only a single default rule without `invert`, its fields are considered merged into the outer rule per the logic above; otherwise, it is matched as an `other field`; different rule-sets always keep OR semantics. #### inbound diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 87d24df77..c280d368e 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -210,9 +210,9 @@ icon: material/new-box (`port` || `port_range`) && (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && (`source_port` || `source_port_range`) && - `other fields` + `其他字段` - 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + 当规则集仅包含一条默认规则且非 invert 时,其中字段视为按以上规则与外层规则合并;否则,作为一条 `其他字段` 匹配;不同规则集之间始终保持 or。 #### inbound diff --git a/route/rule/match_state.go b/route/rule/match_state.go index 0d2e4b0b4..f7f937f87 100644 --- a/route/rule/match_state.go +++ b/route/rule/match_state.go @@ -1,7 +1,5 @@ package rule -import "github.com/sagernet/sing-box/adapter" - type ruleMatchState uint8 const ( @@ -11,108 +9,18 @@ const ( ruleMatchDestinationPort ) -type ruleMatchStateSet uint16 - -func singleRuleMatchState(state ruleMatchState) ruleMatchStateSet { - return 1 << state +type ruleGroupMatch struct { + required ruleMatchState + satisfied ruleMatchState } -func emptyRuleMatchState() ruleMatchStateSet { - return singleRuleMatchState(0) +func (g ruleGroupMatch) done() bool { + return g.required&^g.satisfied == 0 } -func (s ruleMatchStateSet) isEmpty() bool { - return s == 0 -} - -func (s ruleMatchStateSet) contains(state ruleMatchState) bool { - return s&(1< 0 { + return true + } + return !matched + } + return matched +} + +func (r *abstractDefaultRule) matchInner(metadata *adapter.InboundContext) bool { + groups := r.evaluateGroups(metadata) + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + return false + } + } + if r.ruleSetItem != nil { + metadata.DidMatch = true + return r.ruleSetItem.matchWithOuterGroups(metadata, groups) + } + return groups.done() +} + +func (r *abstractDefaultRule) evaluateForMerge(metadata *adapter.InboundContext) (ruleGroupMatch, bool) { + groups := r.evaluateGroups(metadata) + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + return ruleGroupMatch{}, false + } + } + return groups, true } func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.InboundContext) bool { @@ -63,112 +99,51 @@ func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adap return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } -func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.sourceAddressItems) > 0 || r.destinationIPCIDRMatchesSource(metadata) -} - -func (r *abstractDefaultRule) requiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.destinationAddressItems) > 0 || r.destinationIPCIDRMatchesDestination(metadata) -} - -func (r *abstractDefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) -} - -func (r *abstractDefaultRule) matchStatesWithBase(metadata *adapter.InboundContext, inheritedBase ruleMatchState) ruleMatchStateSet { - if len(r.allItems) == 0 { - return emptyRuleMatchState().withBase(inheritedBase) - } - evaluationBase := inheritedBase - if r.invert { - evaluationBase = 0 - } - baseState := evaluationBase +func (r *abstractDefaultRule) evaluateGroups(metadata *adapter.InboundContext) ruleGroupMatch { + var groups ruleGroupMatch if len(r.sourceAddressItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchSourceAddress if matchAnyItem(r.sourceAddressItems, metadata) { - baseState |= ruleMatchSourceAddress + groups.satisfied |= ruleMatchSourceAddress } } - if r.destinationIPCIDRMatchesSource(metadata) && !baseState.has(ruleMatchSourceAddress) { + if r.destinationIPCIDRMatchesSource(metadata) { metadata.DidMatch = true - if matchAnyItem(r.destinationIPCIDRItems, metadata) { - baseState |= ruleMatchSourceAddress + groups.required |= ruleMatchSourceAddress + if !groups.satisfied.has(ruleMatchSourceAddress) && matchAnyItem(r.destinationIPCIDRItems, metadata) { + groups.satisfied |= ruleMatchSourceAddress } - } else if r.destinationIPCIDRMatchesSource(metadata) { - metadata.DidMatch = true } if len(r.sourcePortItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchSourcePort if matchAnyItem(r.sourcePortItems, metadata) { - baseState |= ruleMatchSourcePort + groups.satisfied |= ruleMatchSourcePort } } if len(r.destinationAddressItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchDestinationAddress if matchAnyItem(r.destinationAddressItems, metadata) { - baseState |= ruleMatchDestinationAddress + groups.satisfied |= ruleMatchDestinationAddress } } - if r.destinationIPCIDRMatchesDestination(metadata) && !baseState.has(ruleMatchDestinationAddress) { + if r.destinationIPCIDRMatchesDestination(metadata) { metadata.DidMatch = true - if matchAnyItem(r.destinationIPCIDRItems, metadata) { - baseState |= ruleMatchDestinationAddress + groups.required |= ruleMatchDestinationAddress + if !groups.satisfied.has(ruleMatchDestinationAddress) && matchAnyItem(r.destinationIPCIDRItems, metadata) { + groups.satisfied |= ruleMatchDestinationAddress } - } else if r.destinationIPCIDRMatchesDestination(metadata) { - metadata.DidMatch = true } if len(r.destinationPortItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchDestinationPort if matchAnyItem(r.destinationPortItems, metadata) { - baseState |= ruleMatchDestinationPort + groups.satisfied |= ruleMatchDestinationPort } } - for _, item := range r.items { - metadata.DidMatch = true - if !item.Match(metadata) { - return r.invertedFailure(inheritedBase) - } - } - var stateSet ruleMatchStateSet - if r.ruleSetItem != nil { - metadata.DidMatch = true - stateSet = matchRuleItemStatesWithBase(r.ruleSetItem, metadata, baseState) - } else { - stateSet = singleRuleMatchState(baseState) - } - stateSet = stateSet.filter(func(state ruleMatchState) bool { - if r.requiresSourceAddressMatch(metadata) && !state.has(ruleMatchSourceAddress) { - return false - } - if len(r.sourcePortItems) > 0 && !state.has(ruleMatchSourcePort) { - return false - } - if r.requiresDestinationAddressMatch(metadata) && !state.has(ruleMatchDestinationAddress) { - return false - } - if len(r.destinationPortItems) > 0 && !state.has(ruleMatchDestinationPort) { - return false - } - return true - }) - if stateSet.isEmpty() { - return r.invertedFailure(inheritedBase) - } - if r.invert { - if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { - return emptyRuleMatchState().withBase(inheritedBase) - } - return 0 - } - return stateSet -} - -func (r *abstractDefaultRule) invertedFailure(base ruleMatchState) ruleMatchStateSet { - if r.invert { - return emptyRuleMatchState().withBase(base) - } - return 0 + return groups } func (r *abstractDefaultRule) Action() adapter.RuleAction { @@ -226,50 +201,31 @@ func (r *abstractLogicalRule) Close() error { } func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() -} - -func (r *abstractLogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) -} - -func (r *abstractLogicalRule) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - evaluationBase := base - if r.invert { - evaluationBase = 0 - } - var stateSet ruleMatchStateSet + var matched bool if r.mode == C.LogicalTypeAnd { - stateSet = emptyRuleMatchState().withBase(evaluationBase) + matched = true for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - nestedStateSet := matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase) - if nestedStateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState().withBase(base) - } - return 0 + if !rule.Match(&nestedMetadata) { + matched = false + break } - stateSet = stateSet.combine(nestedStateSet) } } else { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) - } - if stateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState().withBase(base) + if rule.Match(&nestedMetadata) { + matched = true + break } - return 0 } } if r.invert { - return 0 + return !matched } - return stateSet + return matched } func (r *abstractLogicalRule) Action() adapter.RuleAction { diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 774e1b7c0..3aef4a1c8 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -47,10 +47,6 @@ type DefaultRule struct { abstractDefaultRule } -func (r *DefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - type RuleItem interface { Match(metadata *adapter.InboundContext) bool String() string @@ -309,10 +305,6 @@ type LogicalRule struct { abstractLogicalRule } -func (r *LogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) { action, err := NewRuleAction(ctx, logger, options.RuleAction) if err != nil { diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 6b8712d46..2b7682828 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -72,10 +72,6 @@ type DefaultDNSRule struct { matchResponse bool } -func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyDNSMode bool) (*DefaultDNSRule, error) { rule := &DefaultDNSRule{ abstractDefaultRule: abstractDefaultRule{ @@ -365,17 +361,11 @@ func (r *DefaultDNSRule) WithAddressLimit() bool { if len(r.destinationIPCIDRItems) > 0 { return true } - if r.ruleSetItem != nil { - ruleSet, isRuleSet := r.ruleSetItem.(*RuleSetItem) - if isRuleSet && ruleSet.ContainsDestinationIPCIDRRule() { - return true - } - } - return false + return r.ruleSetItem != nil && r.ruleSetItem.ContainsDestinationIPCIDRRule() } func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStatesForMatch(metadata).isEmpty() + return r.matchForMatch(metadata) } func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { @@ -384,26 +374,26 @@ func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { } metadata.IgnoreDestinationIPCIDRMatch = true defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() - return !r.abstractDefaultRule.matchStates(metadata).isEmpty() + return r.abstractDefaultRule.Match(metadata) } -func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { +func (r *DefaultDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { if r.matchResponse { if metadata.DNSResponse == nil { - return r.abstractDefaultRule.invertedFailure(0) + return r.invert } matchMetadata := *metadata matchMetadata.DestinationAddressMatchFromResponse = true - return r.abstractDefaultRule.matchStates(&matchMetadata) + return r.abstractDefaultRule.Match(&matchMetadata) } - return r.abstractDefaultRule.matchStates(metadata) + return r.abstractDefaultRule.Match(metadata) } func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true - return !r.abstractDefaultRule.matchStates(&matchMetadata).isEmpty() + return r.abstractDefaultRule.Match(&matchMetadata) } var _ adapter.DNSRule = (*LogicalDNSRule)(nil) @@ -412,54 +402,43 @@ type LogicalDNSRule struct { abstractLogicalRule } -func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - -func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { +func matchDNSHeadlessRuleForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) bool { switch typedRule := rule.(type) { case *DefaultDNSRule: - return typedRule.matchStatesForMatch(metadata) + return typedRule.matchForMatch(metadata) case *LogicalDNSRule: - return typedRule.matchStatesForMatch(metadata) + return typedRule.matchForMatch(metadata) default: - return matchHeadlessRuleStatesWithBase(typedRule, metadata, 0) + return typedRule.Match(metadata) } } -func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { - var stateSet ruleMatchStateSet +func (r *LogicalDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { + var matched bool if r.mode == C.LogicalTypeAnd { - stateSet = emptyRuleMatchState() + matched = true for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - nestedStateSet := matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata) - if nestedStateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState() - } - return 0 + if !matchDNSHeadlessRuleForMatch(rule, &nestedMetadata) { + matched = false + break } - stateSet = stateSet.combine(nestedStateSet) } } else { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata)) - } - if stateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState() + if matchDNSHeadlessRuleForMatch(rule, &nestedMetadata) { + matched = true + break } - return 0 } } if r.invert { - return 0 + return !matched } - return stateSet + return matched } func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule, legacyDNSMode bool) (*LogicalDNSRule, error) { @@ -513,18 +492,18 @@ func (r *LogicalDNSRule) WithAddressLimit() bool { } func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStatesForMatch(metadata).isEmpty() + return r.matchForMatch(metadata) } func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { metadata.IgnoreDestinationIPCIDRMatch = true defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() - return !r.abstractLogicalRule.matchStates(metadata).isEmpty() + return r.abstractLogicalRule.Match(metadata) } func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true - return !r.abstractLogicalRule.matchStates(&matchMetadata).isEmpty() + return r.abstractLogicalRule.Match(&matchMetadata) } diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index ab85e0d5f..b131a5ea6 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -34,10 +34,6 @@ type DefaultHeadlessRule struct { abstractDefaultRule } -func (r *DefaultHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessRule) (*DefaultHeadlessRule, error) { networkManager := service.FromContext[adapter.NetworkManager](ctx) rule := &DefaultHeadlessRule{ @@ -216,10 +212,6 @@ type LogicalHeadlessRule struct { abstractLogicalRule } -func (r *LogicalHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessRule) (*LogicalHeadlessRule, error) { r := &LogicalHeadlessRule{ abstractLogicalRule{ diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go index 013649435..505650b17 100644 --- a/route/rule/rule_item_rule_set.go +++ b/route/rule/rule_item_rule_set.go @@ -52,23 +52,68 @@ func (r *RuleSetItem) Close() error { } func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() -} - -func (r *RuleSetItem) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) -} - -func (r *RuleSetItem) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet for _, ruleSet := range r.setList { + nestedMetadata := r.nestedMetadata(metadata) + if ruleSet.Match(&nestedMetadata) { + return true + } + } + return false +} + +func (r *RuleSetItem) matchWithOuterGroups(metadata *adapter.InboundContext, outerGroups ruleGroupMatch) bool { + outerDone := outerGroups.done() + for _, ruleSet := range r.setList { + nestedMetadata := r.nestedMetadata(metadata) + if provider, isProvider := ruleSet.(mergeableRuleProvider); isProvider { + branch := provider.mergeableRule() + if branch != nil { + branchGroups, branchMatched := branch.evaluateForMerge(&nestedMetadata) + if branchMatched && outerGroups.mergeWith(branchGroups).done() { + return true + } + continue + } + } + if outerDone && ruleSet.Match(&nestedMetadata) { + return true + } + } + return false +} + +func (r *RuleSetItem) nestedMetadata(metadata *adapter.InboundContext) adapter.InboundContext { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource + nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty + return nestedMetadata +} + +type mergeableRuleProvider interface { + mergeableRule() *DefaultHeadlessRule +} + +func mergeableRuleIn(rules []adapter.HeadlessRule) *DefaultHeadlessRule { + if len(rules) != 1 { + return nil + } + rule, isDefault := rules[0].(*DefaultHeadlessRule) + if !isDefault || rule.invert || rule.ruleSetItem != nil { + return nil + } + return rule +} + +func matchAnyHeadlessRule(rules []adapter.HeadlessRule, metadata *adapter.InboundContext) bool { + for _, rule := range rules { nestedMetadata := *metadata nestedMetadata.ResetRuleMatchCache() - nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource - nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) + if rule.Match(&nestedMetadata) { + return true + } } - return stateSet + return false } func (r *RuleSetItem) ContainsDestinationIPCIDRRule() bool { diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index 9fcbe685b..9d5d21e83 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -199,19 +199,9 @@ func (s *LocalRuleSet) Close() error { } func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { - return !s.matchStates(metadata).isEmpty() + return matchAnyHeadlessRule(s.rules, metadata) } -func (s *LocalRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return s.matchStatesWithBase(metadata, 0) -} - -func (s *LocalRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet +func (s *LocalRuleSet) mergeableRule() *DefaultHeadlessRule { + return mergeableRuleIn(s.rules) } diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index 935658b32..56a3ea53c 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -297,19 +297,9 @@ func (s *RemoteRuleSet) Close() error { } func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { - return !s.matchStates(metadata).isEmpty() + return matchAnyHeadlessRule(s.rules, metadata) } -func (s *RemoteRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return s.matchStatesWithBase(metadata, 0) -} - -func (s *RemoteRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet +func (s *RemoteRuleSet) mergeableRule() *DefaultHeadlessRule { + return mergeableRuleIn(s.rules) } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 58f5d528e..0f63a0548 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -295,11 +295,11 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("later rule in same set can satisfy outer group", func(t *testing.T) { + t.Run("multi rule set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest( - "rule-set-or", + "rule-set-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) }), @@ -311,7 +311,7 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + require.False(t, rule.Match(&metadata)) }) t.Run("cross ruleset union is not allowed", func(t *testing.T) { t.Parallel() @@ -333,7 +333,7 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { func TestRouteRuleSetLogicalSemantics(t *testing.T) { t.Parallel() - t.Run("logical or keeps all successful branch states", func(t *testing.T) { + t.Run("logical set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-or", headlessLogicalRule( @@ -350,9 +350,9 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + require.False(t, rule.Match(&metadata)) }) - t.Run("logical and unions child states", func(t *testing.T) { + t.Run("logical branch does not lift outer group requirements", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-and", headlessLogicalRule( @@ -370,9 +370,28 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) addSourcePortItem(rule, []uint16{2000}) }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("logical branch matches on its own conditions", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-and-self", headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourcePortItem(rule, []uint16{1000}) + }) require.True(t, rule.Match(&metadata)) }) - t.Run("invert success does not contribute positive state", func(t *testing.T) { + t.Run("inverted set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -387,9 +406,240 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { }) } -func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { +func TestRuleSetShapeBoundary(t *testing.T) { t.Parallel() - t.Run("default invert keeps inherited group outside grouped predicate", func(t *testing.T) { + buildOuter := func(ruleSet *LocalRuleSet) *DefaultRule { + return routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, []string{"extra.example.org"}, nil) + addDestinationPortItem(rule, []uint16{443}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + } + singleShape := buildOuter(newLocalRuleSetForTest("flat-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.com", "b.example.com"}) + }))) + multiShape := buildOuter(newLocalRuleSetForTest( + "flat-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"b.example.com"}) + }), + )) + queries := []struct { + name string + domain string + port uint16 + singleResult bool + multiResult bool + }{ + {"in set", "www.b.example.com", 443, true, false}, + {"outer own domain", "extra.example.org", 443, true, false}, + {"neither", "other.example.net", 443, false, false}, + {"in set with wrong port", "www.b.example.com", 80, false, false}, + } + for _, query := range queries { + t.Run(query.name, func(t *testing.T) { + t.Parallel() + singleMetadata := testMetadata(query.domain) + singleMetadata.Destination.Port = query.port + multiMetadata := testMetadata(query.domain) + multiMetadata.Destination.Port = query.port + require.Equal(t, query.singleResult, singleShape.Match(&singleMetadata)) + require.Equal(t, query.multiResult, multiShape.Match(&multiMetadata)) + }) + } +} + +func TestRuleSetCaseBoundary(t *testing.T) { + t.Parallel() + t.Run("single cross group rule merges into outer", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("port-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{8080}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("multi rule set keeps outer group absolute", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "port-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"never.example"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{8080}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRuleSetLogicalBranchSelfContained(t *testing.T) { + t.Parallel() + newRuleSet := func() *LocalRuleSet { + return newLocalRuleSetForTest("and-branch", headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"b.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"800:900"}) + }), + )) + } + t.Run("branch matches only on its own conditions", func(t *testing.T) { + t.Parallel() + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + matchedMetadata := testMetadata("www.b.example.com") + matchedMetadata.Destination.Port = 850 + require.True(t, rule.Match(&matchedMetadata)) + unmatchedMetadata := testMetadata("www.b.example.com") + require.False(t, rule.Match(&unmatchedMetadata)) + }) + t.Run("outer condition and set are both required", func(t *testing.T) { + t.Parallel() + matchedRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + metadata := testMetadata("www.b.example.com") + metadata.Destination.Port = 850 + require.True(t, matchedRule.Match(&metadata)) + setMissMetadata := testMetadata("other.example.net") + setMissMetadata.Destination.Port = 850 + require.False(t, matchedRule.Match(&setMissMetadata)) + outerMissRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{2000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + outerMissMetadata := testMetadata("www.b.example.com") + outerMissMetadata.Destination.Port = 850 + require.False(t, outerMissRule.Match(&outerMissMetadata)) + }) +} + +func TestRuleSetMixedReference(t *testing.T) { + t.Parallel() + t.Run("single rule set merges as or", func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("mixed-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set.example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, []string{"extra.example.org"}, nil) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + extraMetadata := testMetadata("extra.example.org") + require.True(t, rule.Match(&extraMetadata)) + setMetadata := testMetadata("www.set.example.com") + require.True(t, rule.Match(&setMetadata)) + otherMetadata := testMetadata("other.example.net") + require.False(t, rule.Match(&otherMetadata)) + }) + t.Run("multi rule set is an independent condition", func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest( + "mixed-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set2.example.com"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{443}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + matchedMetadata := testMetadata("www.set2.example.com") + require.True(t, rule.Match(&matchedMetadata)) + portMissMetadata := testMetadata("www.set2.example.com") + portMissMetadata.Destination.Port = 80 + require.False(t, rule.Match(&portMissMetadata)) + setMissMetadata := testMetadata("other.example.net") + require.False(t, rule.Match(&setMissMetadata)) + }) +} + +func TestRuleSetStandaloneReference(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest( + "standalone", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.net"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + domainMetadata := testMetadata("www.a.example.net") + domainMetadata.Destination.Port = 80 + require.True(t, rule.Match(&domainMetadata)) + portMetadata := testMetadata("other.example.org") + require.True(t, rule.Match(&portMetadata)) + missMetadata := testMetadata("other.example.org") + missMetadata.Destination.Port = 80 + require.False(t, rule.Match(&missMetadata)) +} + +func TestRuleSetInvertedSingleRuleIsBoolean(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("inverted-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"blocked.example"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + allowedMetadata := testMetadata("good.example.org") + require.True(t, rule.Match(&allowedMetadata)) + blockedMetadata := testMetadata("www.blocked.example") + require.False(t, rule.Match(&blockedMetadata)) +} + +func TestRuleSetEmptySetNeverMatches(t *testing.T) { + t.Parallel() + emptySet := newLocalRuleSetForTest("empty") + t.Run("outer own group does not bypass the set", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptySet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("standalone reference does not match", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptySet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetInvertBranchSemantics(t *testing.T) { + t.Parallel() + t.Run("inverted default branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -402,7 +652,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("default invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Run("inverted default branch with non grouped condition acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert-network", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -415,7 +665,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("logical invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Run("inverted logical branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-invert-grouped", headlessLogicalRule( @@ -431,7 +681,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("logical invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Run("inverted logical branch with non grouped condition acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-invert-network", headlessLogicalRule( @@ -498,21 +748,26 @@ func TestDefaultRuleDoesNotReuseGroupedMatchCacheAcrossEvaluations(t *testing.T) func TestRouteRuleSetRemoteUsesSameSemantics(t *testing.T) { t.Parallel() - metadata := testMetadata("www.example.com") ruleSet := newRemoteRuleSetForTest( "remote", headlessDefaultRule(t, func(rule *abstractDefaultRule) { - addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) }), headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationAddressItem(t, rule, nil, []string{"example.com"}) }), ) - rule := routeRuleForTest(func(rule *abstractDefaultRule) { + standaloneRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + standaloneMetadata := testMetadata("www.example.com") + require.True(t, standaloneRule.Match(&standaloneMetadata)) + combinedRule := routeRuleForTest(func(rule *abstractDefaultRule) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + combinedMetadata := testMetadata("www.example.com") + require.False(t, combinedRule.Match(&combinedMetadata)) } func TestDNSRuleSetSemantics(t *testing.T) { @@ -541,7 +796,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { }) require.False(t, rule.Match(&metadata)) }) - t.Run("outer destination group stays outside inverted grouped branch", func(t *testing.T) { + t.Run("inverted branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.baidu.com") ruleSet := newLocalRuleSetForTest("dns-invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -554,7 +809,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("outer destination group stays outside inverted logical branch", func(t *testing.T) { + t.Run("inverted logical branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("dns-logical-invert-network", headlessLogicalRule(