diff --git a/internal/corecfg/manager.go b/internal/corecfg/manager.go index 6db2328..f511ef8 100644 --- a/internal/corecfg/manager.go +++ b/internal/corecfg/manager.go @@ -381,7 +381,7 @@ func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]int combined = deepMerge(combined, data) } - combined["rules"] = dedupeMatchRule(dedupeGeoIPRule(combined["rules"])) + combined["rules"] = dedupeMatchRule(dedupeGeoIPRule(dedupeGenericRule(combined["rules"]))) mergeProxySelection(combined, selectionGroups) return combined, proxies, true @@ -421,14 +421,14 @@ func dedupeGeoIPRule(rulesRaw interface{}) interface{} { lastIndex := make(map[string]int) for i, item := range rules { - if key, ok := geoIPRuleKey(item); ok { + if ruleType, key, ok := ruleKey(item); ok && ruleType == "GEOIP" { lastIndex[key] = i } } filtered := make([]interface{}, 0, len(rules)) for i, item := range rules { - if key, ok := geoIPRuleKey(item); ok && lastIndex[key] != i { + if ruleType, key, ok := ruleKey(item); ok && ruleType == "GEOIP" && lastIndex[key] != i { continue } filtered = append(filtered, item) @@ -436,13 +436,50 @@ func dedupeGeoIPRule(rulesRaw interface{}) interface{} { return filtered } -// geoIPRuleKey returns the "GEOIP," portion of a rule line (i.e. -// everything but its target and any trailing "no-resolve" modifier), and -// whether the line is a GEOIP rule at all. -func geoIPRuleKey(item interface{}) (string, bool) { +// dedupeGenericRule keeps only the first occurrence of each +// "TYPE,PAYLOAD" rule (e.g. "DOMAIN,example.com,...", +// "DOMAIN-SUFFIX,google.com,...", "IP-CIDR,10.0.0.0/8,...") contributed by +// the merged subscriptions, dropping the rest: Clash evaluates rules +// top-to-bottom, so once an earlier rule matches a given payload, any later +// duplicate for that same payload could never fire anyway. MATCH and GEOIP +// rules have their own dedup semantics (dedupeMatchRule, dedupeGeoIPRule), +// so they pass through here untouched. +func dedupeGenericRule(rulesRaw interface{}) interface{} { + rules, ok := rulesRaw.([]interface{}) + if !ok { + return rulesRaw + } + + seen := make(map[string]bool) + filtered := make([]interface{}, 0, len(rules)) + for _, item := range rules { + ruleType, key, ok := ruleKey(item) + if !ok || ruleType == "MATCH" || ruleType == "GEOIP" { + filtered = append(filtered, item) + continue + } + if seen[key] { + continue + } + seen[key] = true + filtered = append(filtered, item) + } + return filtered +} + +// ruleKey returns a rule line's type (e.g. "DOMAIN", "GEOIP", "MATCH") and +// its "TYPE,PAYLOAD" portion — everything but the target and any trailing +// "no-resolve" modifier — plus whether the line parses as a well-formed +// "TYPE,PAYLOAD,TARGET" (or "TYPE,TARGET" for MATCH) rule at all. +func ruleKey(item interface{}) (ruleType, key string, ok bool) { line, ok := item.(string) - if !ok || !strings.HasPrefix(line, "GEOIP,") { - return "", false + if !ok { + return "", "", false + } + + typeEnd := strings.Index(line, ",") + if typeEnd == -1 { + return "", "", false } body := line @@ -452,9 +489,10 @@ func geoIPRuleKey(item interface{}) (string, bool) { idx := strings.LastIndex(body, ",") if idx == -1 { - return line, true + return "", "", false } - return body[:idx], true + + return line[:typeEnd], body[:idx], true } func openInEditor(path string) bool { diff --git a/internal/corecfg/manager_test.go b/internal/corecfg/manager_test.go index 1a53b31..99f3652 100644 --- a/internal/corecfg/manager_test.go +++ b/internal/corecfg/manager_test.go @@ -58,3 +58,36 @@ func TestDedupeGeoIPRuleNonList(t *testing.T) { t.Fatalf("expected nil passthrough, got %#v", got) } } + +func TestDedupeGenericRule(t *testing.T) { + rules := []interface{}{ + "DOMAIN,example.com,sub-a | Proxy", + "DOMAIN-SUFFIX,google.com,sub-a | Proxy", + "DOMAIN,example.com,sub-b | Proxy", + "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", + "DOMAIN-SUFFIX,google.com,sub-b | Proxy", + "IP-CIDR,10.0.0.0/8,sub-b | Proxy", + "GEOIP,CN,DIRECT", + "MATCH,sub-a | Auto", + } + + got := dedupeGenericRule(rules) + + want := []interface{}{ + "DOMAIN,example.com,sub-a | Proxy", + "DOMAIN-SUFFIX,google.com,sub-a | Proxy", + "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", + "GEOIP,CN,DIRECT", + "MATCH,sub-a | Auto", + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestDedupeGenericRuleNonList(t *testing.T) { + if got := dedupeGenericRule(nil); got != nil { + t.Fatalf("expected nil passthrough, got %#v", got) + } +}