Files
SingboxForPanel/route/rule/rule_abstract.go
世界 d454aa0fdf route: formalize nested rule_set group-state semantics
Before 795d1c289, nested rule-set evaluation reused the parent rule
match cache. In practice, this meant these fields leaked across nested
evaluation:

- SourceAddressMatch
- SourcePortMatch
- DestinationAddressMatch
- DestinationPortMatch
- DidMatch

That leak had two opposite effects.

First, it made included rule-sets partially behave like the docs'
"merged" semantics. For example, if an outer route rule had:

  rule_set = ["geosite-additional-!cn"]
  ip_cidr  = 104.26.10.0/24

and the inline rule-set matched `domain_suffix = speedtest.net`, the
inner match could set `DestinationAddressMatch = true` and the outer
rule would then pass its destination-address group check. This is why
some `rule_set + ip_cidr` combinations used to work.

But the same leak also polluted sibling rules and sibling rule-sets.
A branch could partially match one group, then fail later, and still
leave that group cache set for the next branch. This broke cases such
as gh-3485: with `rule_set = [test1, test2]`, `test1` could touch
destination-address cache before an AdGuard `@@` exclusion made the
whole branch fail, and `test2` would then run against dirty state.

795d1c289 fixed that by cloning metadata for nested rule-set/rule
evaluation and resetting the rule match cache for each branch. That
stopped sibling pollution, but it also removed the only mechanism by
which a successful nested branch could affect the parent rule's grouped
matching state.

As a result, nested rule-sets became pure boolean sub-items against the
outer rule. The previous example stopped working: the inner
`domain_suffix = speedtest.net` still matched, but the outer rule no
longer observed any destination-address-group success, so it fell
through to `final`.

This change makes the semantics explicit instead of relying on cache
side effects:

- `rule_set: ["a", "b"]` is OR
- rules inside one rule-set are OR
- each nested branch is evaluated in isolation
- failed branches contribute no grouped match state
- a successful branch contributes its grouped match state back to the
  parent rule
- grouped state from different rule-sets must not be combined together
  to satisfy one outer rule

In other words, rule-sets now behave as "OR branches whose successful
group matches merge into the outer rule", which matches the documented
intent without reintroducing cross-branch cache leakage.
2026-03-24 15:03:43 +08:00

290 lines
7.4 KiB
Go

package rule
import (
"io"
"strings"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing/common"
F "github.com/sagernet/sing/common/format"
)
type abstractDefaultRule struct {
items []RuleItem
sourceAddressItems []RuleItem
sourcePortItems []RuleItem
destinationAddressItems []RuleItem
destinationIPCIDRItems []RuleItem
destinationPortItems []RuleItem
allItems []RuleItem
ruleSetItem RuleItem
invert bool
action adapter.RuleAction
}
func (r *abstractDefaultRule) Type() string {
return C.RuleTypeDefault
}
func (r *abstractDefaultRule) Start() error {
for _, item := range r.allItems {
if starter, isStarter := item.(interface {
Start() error
}); isStarter {
err := starter.Start()
if err != nil {
return err
}
}
}
return nil
}
func (r *abstractDefaultRule) Close() error {
for _, item := range r.allItems {
err := common.Close(item)
if err != nil {
return err
}
}
return nil
}
func (r *abstractDefaultRule) Match(metadata *adapter.InboundContext) bool {
return !r.matchStates(metadata).isEmpty()
}
func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.InboundContext) bool {
return !metadata.IgnoreDestinationIPCIDRMatch && metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0
}
func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool {
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 {
if len(r.allItems) == 0 {
return emptyRuleMatchState()
}
var baseState ruleMatchState
if len(r.sourceAddressItems) > 0 {
metadata.DidMatch = true
if matchAnyItem(r.sourceAddressItems, metadata) {
baseState |= ruleMatchSourceAddress
}
}
if r.destinationIPCIDRMatchesSource(metadata) && !baseState.has(ruleMatchSourceAddress) {
metadata.DidMatch = true
if matchAnyItem(r.destinationIPCIDRItems, metadata) {
baseState |= ruleMatchSourceAddress
}
} else if r.destinationIPCIDRMatchesSource(metadata) {
metadata.DidMatch = true
}
if len(r.sourcePortItems) > 0 {
metadata.DidMatch = true
if matchAnyItem(r.sourcePortItems, metadata) {
baseState |= ruleMatchSourcePort
}
}
if len(r.destinationAddressItems) > 0 {
metadata.DidMatch = true
if matchAnyItem(r.destinationAddressItems, metadata) {
baseState |= ruleMatchDestinationAddress
}
}
if r.destinationIPCIDRMatchesDestination(metadata) && !baseState.has(ruleMatchDestinationAddress) {
metadata.DidMatch = true
if matchAnyItem(r.destinationIPCIDRItems, metadata) {
baseState |= ruleMatchDestinationAddress
}
} else if r.destinationIPCIDRMatchesDestination(metadata) {
metadata.DidMatch = true
}
if len(r.destinationPortItems) > 0 {
metadata.DidMatch = true
if matchAnyItem(r.destinationPortItems, metadata) {
baseState |= ruleMatchDestinationPort
}
}
for _, item := range r.items {
metadata.DidMatch = true
if !item.Match(metadata) {
return r.invertedFailure()
}
}
stateSet := singleRuleMatchState(baseState)
if r.ruleSetItem != nil {
metadata.DidMatch = true
ruleSetStates := matchRuleItemStates(r.ruleSetItem, metadata)
if ruleSetStates.isEmpty() {
return r.invertedFailure()
}
stateSet = ruleSetStates.withBase(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()
}
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 0
}
return stateSet
}
func (r *abstractDefaultRule) invertedFailure() ruleMatchStateSet {
if r.invert {
return emptyRuleMatchState()
}
return 0
}
func (r *abstractDefaultRule) Action() adapter.RuleAction {
return r.action
}
func (r *abstractDefaultRule) String() string {
if !r.invert {
return strings.Join(F.MapToString(r.allItems), " ")
} else {
return "!(" + strings.Join(F.MapToString(r.allItems), " ") + ")"
}
}
type abstractLogicalRule struct {
rules []adapter.HeadlessRule
mode string
invert bool
action adapter.RuleAction
}
func (r *abstractLogicalRule) Type() string {
return C.RuleTypeLogical
}
func (r *abstractLogicalRule) Start() error {
for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (interface {
Start() error
}, bool,
) {
rule, loaded := it.(interface {
Start() error
})
return rule, loaded
}) {
err := rule.Start()
if err != nil {
return err
}
}
return nil
}
func (r *abstractLogicalRule) Close() error {
for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (io.Closer, bool) {
rule, loaded := it.(io.Closer)
return rule, loaded
}) {
err := rule.Close()
if err != nil {
return err
}
}
return nil
}
func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool {
return !r.matchStates(metadata).isEmpty()
}
func (r *abstractLogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet {
var stateSet ruleMatchStateSet
if r.mode == C.LogicalTypeAnd {
stateSet = emptyRuleMatchState()
for _, rule := range r.rules {
nestedMetadata := *metadata
nestedMetadata.ResetRuleCache()
nestedStateSet := matchHeadlessRuleStates(rule, &nestedMetadata)
if nestedStateSet.isEmpty() {
if r.invert {
return emptyRuleMatchState()
}
return 0
}
stateSet = stateSet.combine(nestedStateSet)
}
} else {
for _, rule := range r.rules {
nestedMetadata := *metadata
nestedMetadata.ResetRuleCache()
stateSet = stateSet.merge(matchHeadlessRuleStates(rule, &nestedMetadata))
}
if stateSet.isEmpty() {
if r.invert {
return emptyRuleMatchState()
}
return 0
}
}
if r.invert {
return 0
}
return stateSet
}
func (r *abstractLogicalRule) Action() adapter.RuleAction {
return r.action
}
func (r *abstractLogicalRule) String() string {
var op string
switch r.mode {
case C.LogicalTypeAnd:
op = "&&"
case C.LogicalTypeOr:
op = "||"
}
if !r.invert {
return strings.Join(F.MapToString(r.rules), " "+op+" ")
} else {
return "!(" + strings.Join(F.MapToString(r.rules), " "+op+" ") + ")"
}
}
func matchAnyItem(items []RuleItem, metadata *adapter.InboundContext) bool {
return common.Any(items, func(it RuleItem) bool {
return it.Match(metadata)
})
}
func (s ruleMatchState) has(target ruleMatchState) bool {
return s&target != 0
}