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.
This commit is contained in:
世界
2026-03-25 10:32:09 +08:00
parent 7425100bac
commit b0c6762bc1
10 changed files with 308 additions and 32 deletions

View File

@@ -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")