From b0c6762bc15a7175a50cb719074736c8520939b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 25 Mar 2026 10:32:09 +0800 Subject: [PATCH] route: merge rule_set branches into outer rules Treat rule_set items as merged branches instead of standalone boolean sub-items. Evaluate each branch inside a referenced rule-set as if it were merged into the outer rule and keep OR semantics between branches. This lets outer grouped fields satisfy matching groups inside a branch without introducing a standalone outer fallback or cross-branch state union. Keep inherited grouped state outside inverted default and logical branches. Negated rule-set branches now evaluate !(...) against their own conditions and only reapply the outer grouped match after negation succeeds, so configs like outer-group && !inner-condition continue to work. Add regression tests for same-group merged matches, cross-group and extra-AND failures, DNS merged-branch behaviour, and inverted merged branches. Update the route and DNS rule docs to clarify that rule-set branches merge into the outer rule while keeping OR semantics between branches. --- docs/configuration/dns/rule.md | 4 +- 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 | 26 ++- route/rule/rule_abstract.go | 50 ++++-- route/rule/rule_item_rule_set.go | 6 +- route/rule/rule_set_local.go | 6 +- route/rule/rule_set_remote.go | 6 +- route/rule/rule_set_semantics_test.go | 232 ++++++++++++++++++++++++++ 10 files changed, 308 insertions(+), 32 deletions(-) diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 6407e1bf..43486748 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -209,7 +209,7 @@ icon: material/alert-decagram (`source_port` || `source_port_range`) && `other fields` - Additionally, included rule-sets can be considered merged rather than as a single rule sub-item. + Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. #### inbound @@ -546,4 +546,4 @@ Match any IP with query response. #### rules -Included rules. \ No newline at end of file +Included rules. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index c46bc475..f35cfc7e 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -208,7 +208,7 @@ icon: material/alert-decagram (`source_port` || `source_port_range`) && `other fields` - 另外,引用的规则集可视为被合并,而不是作为一个单独的规则子项。 + 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 #### inbound @@ -550,4 +550,4 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. ==必填== -包括的规则。 \ No newline at end of file +包括的规则。 diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 31f768fe..92518726 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -199,7 +199,7 @@ icon: material/new-box (`source_port` || `source_port_range`) && `other fields` - Additionally, included rule-sets can be considered merged rather than as a single rule sub-item. + Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. #### inbound diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index c8838018..53da4475 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -197,7 +197,7 @@ icon: material/new-box (`source_port` || `source_port_range`) && `other fields` - 另外,引用的规则集可视为被合并,而不是作为一个单独的规则子项。 + 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 #### inbound @@ -501,4 +501,4 @@ icon: material/new-box ==必填== -包括的规则。 \ No newline at end of file +包括的规则。 diff --git a/route/rule/match_state.go b/route/rule/match_state.go index f537d5de..feac8418 100644 --- a/route/rule/match_state.go +++ b/route/rule/match_state.go @@ -87,22 +87,40 @@ type ruleStateMatcher interface { matchStates(metadata *adapter.InboundContext) ruleMatchStateSet } +type ruleStateMatcherWithBase interface { + matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet +} + func matchHeadlessRuleStates(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { + return matchHeadlessRuleStatesWithBase(rule, metadata, 0) +} + +func matchHeadlessRuleStatesWithBase(rule adapter.HeadlessRule, metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { + if matcher, isStateMatcher := rule.(ruleStateMatcherWithBase); isStateMatcher { + return matcher.matchStatesWithBase(metadata, base) + } if matcher, isStateMatcher := rule.(ruleStateMatcher); isStateMatcher { - return matcher.matchStates(metadata) + return matcher.matchStates(metadata).withBase(base) } if rule.Match(metadata) { - return emptyRuleMatchState() + return emptyRuleMatchState().withBase(base) } return 0 } func matchRuleItemStates(item RuleItem, metadata *adapter.InboundContext) ruleMatchStateSet { + return matchRuleItemStatesWithBase(item, metadata, 0) +} + +func matchRuleItemStatesWithBase(item RuleItem, metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { + if matcher, isStateMatcher := item.(ruleStateMatcherWithBase); isStateMatcher { + return matcher.matchStatesWithBase(metadata, base) + } if matcher, isStateMatcher := item.(ruleStateMatcher); isStateMatcher { - return matcher.matchStates(metadata) + return matcher.matchStates(metadata).withBase(base) } if item.Match(metadata) { - return emptyRuleMatchState() + return emptyRuleMatchState().withBase(base) } return 0 } diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 141f3d27..8a95fa6d 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -72,10 +72,18 @@ func (r *abstractDefaultRule) requiresDestinationAddressMatch(metadata *adapter. } 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() + return emptyRuleMatchState().withBase(inheritedBase) } - var baseState ruleMatchState + evaluationBase := inheritedBase + if r.invert { + evaluationBase = 0 + } + baseState := evaluationBase if len(r.sourceAddressItems) > 0 { metadata.DidMatch = true if matchAnyItem(r.sourceAddressItems, metadata) { @@ -119,17 +127,15 @@ func (r *abstractDefaultRule) matchStates(metadata *adapter.InboundContext) rule for _, item := range r.items { metadata.DidMatch = true if !item.Match(metadata) { - return r.invertedFailure() + return r.invertedFailure(inheritedBase) } } - stateSet := singleRuleMatchState(baseState) + var stateSet ruleMatchStateSet if r.ruleSetItem != nil { metadata.DidMatch = true - ruleSetStates := matchRuleItemStates(r.ruleSetItem, metadata) - if ruleSetStates.isEmpty() { - return r.invertedFailure() - } - stateSet = ruleSetStates.withBase(baseState) + stateSet = matchRuleItemStatesWithBase(r.ruleSetItem, metadata, baseState) + } else { + stateSet = singleRuleMatchState(baseState) } stateSet = stateSet.filter(func(state ruleMatchState) bool { if r.requiresSourceAddressMatch(metadata) && !state.has(ruleMatchSourceAddress) { @@ -147,21 +153,21 @@ func (r *abstractDefaultRule) matchStates(metadata *adapter.InboundContext) rule return true }) if stateSet.isEmpty() { - return r.invertedFailure() + return r.invertedFailure(inheritedBase) } if r.invert { // DNS pre-lookup defers destination address-limit checks until the response phase. if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { - return emptyRuleMatchState() + return emptyRuleMatchState().withBase(inheritedBase) } return 0 } return stateSet } -func (r *abstractDefaultRule) invertedFailure() ruleMatchStateSet { +func (r *abstractDefaultRule) invertedFailure(base ruleMatchState) ruleMatchStateSet { if r.invert { - return emptyRuleMatchState() + return emptyRuleMatchState().withBase(base) } return 0 } @@ -225,16 +231,24 @@ func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { } 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 if r.mode == C.LogicalTypeAnd { - stateSet = emptyRuleMatchState() + stateSet = emptyRuleMatchState().withBase(evaluationBase) for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - nestedStateSet := matchHeadlessRuleStates(rule, &nestedMetadata) + nestedStateSet := matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase) if nestedStateSet.isEmpty() { if r.invert { - return emptyRuleMatchState() + return emptyRuleMatchState().withBase(base) } return 0 } @@ -244,11 +258,11 @@ func (r *abstractLogicalRule) matchStates(metadata *adapter.InboundContext) rule for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(matchHeadlessRuleStates(rule, &nestedMetadata)) + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) } if stateSet.isEmpty() { if r.invert { - return emptyRuleMatchState() + return emptyRuleMatchState().withBase(base) } return 0 } diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go index 0916279d..3467843b 100644 --- a/route/rule/rule_item_rule_set.go +++ b/route/rule/rule_item_rule_set.go @@ -45,13 +45,17 @@ func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { } 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 := *metadata nestedMetadata.ResetRuleMatchCache() nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty - stateSet = stateSet.merge(matchHeadlessRuleStates(ruleSet, &nestedMetadata)) + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) } return stateSet } diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index ec0f91b2..ed873d70 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -206,11 +206,15 @@ func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { } 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(matchHeadlessRuleStates(rule, &nestedMetadata)) + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) } return stateSet } diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index c85dc859..bda6e23f 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -326,11 +326,15 @@ func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { } 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(matchHeadlessRuleStates(rule, &nestedMetadata)) + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) } return stateSet } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 27461ce6..a01defe6 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -149,6 +149,95 @@ func TestRouteRuleSetMergeSourceAndPortGroups(t *testing.T) { }) } +func TestRouteRuleSetOuterGroupedStateMergesIntoSameGroup(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + metadata adapter.InboundContext + buildOuter func(*testing.T, *abstractDefaultRule) + buildInner func(*testing.T, *abstractDefaultRule) + }{ + { + name: "destination address", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + }, + }, + { + name: "source address", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourceAddressItem(t, rule, []string{"10.0.0.0/8"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourceAddressItem(t, rule, []string{"198.51.100.0/24"}) + }, + }, + { + name: "source port", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourcePortItem(rule, []uint16{1000}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourcePortItem(rule, []uint16{2000}) + }, + }, + { + name: "destination port", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationPortItem(rule, []uint16{443}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationPortItem(rule, []uint16{8443}) + }, + }, + { + name: "destination ip cidr", + metadata: func() adapter.InboundContext { + metadata := testMetadata("lookup.example") + metadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("203.0.113.1")} + return metadata + }(), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"198.51.100.0/24"}) + }, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("outer-merge-"+testCase.name, headlessDefaultRule(t, func(rule *abstractDefaultRule) { + testCase.buildInner(t, rule) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + testCase.buildOuter(t, rule) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&testCase.metadata)) + }) + } +} + func TestRouteRuleSetOtherFieldsStayAnd(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") @@ -162,6 +251,34 @@ func TestRouteRuleSetOtherFieldsStayAnd(t *testing.T) { require.False(t, rule.Match(&metadata)) } +func TestRouteRuleSetMergedBranchKeepsAndConstraints(t *testing.T) { + t.Parallel() + t.Run("outer group does not bypass inner non grouped condition", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("network-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("outer group does not satisfy different grouped branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("different-group", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + func TestRouteRuleSetOrSemantics(t *testing.T) { t.Parallel() t.Run("later ruleset can satisfy outer group", func(t *testing.T) { @@ -271,6 +388,68 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { }) } +func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { + t.Parallel() + t.Run("default invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("default invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert-network", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("logical invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-invert-grouped", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("logical invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-invert-network", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) +} + func TestRouteRuleSetNoLeakageRegressions(t *testing.T) { t.Parallel() t.Run("same ruleset failed branch does not leak", func(t *testing.T) { @@ -339,6 +518,59 @@ func TestRouteRuleSetRemoteUsesSameSemantics(t *testing.T) { func TestDNSRuleSetSemantics(t *testing.T) { t.Parallel() + t.Run("outer destination group merges into matching ruleset branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.baidu.com") + ruleSet := newLocalRuleSetForTest("dns-merged-branch", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"baidu.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("outer destination group does not bypass ruleset non grouped condition", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-network-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("outer destination group stays outside inverted grouped branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.baidu.com") + ruleSet := newLocalRuleSetForTest("dns-invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"baidu.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("outer destination group stays outside inverted logical branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-logical-invert-network", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }), + )) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) t.Run("match address limit merges destination group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com")