feat(routing): 档案校验 + 规范化(白名单/上限/去重)
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
// config rendering.
|
||||
package routing
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rule is a single user-authored routing rule (domain/IP/geosite match →
|
||||
// proxy/direct action). Validation of Type/Action/Value lives in Task 2.
|
||||
type Rule struct {
|
||||
@@ -40,3 +45,76 @@ func Default() *Profile {
|
||||
Final: "proxy",
|
||||
}
|
||||
}
|
||||
|
||||
// FieldError describes a single validation failure. Index is the offending
|
||||
// rule's position in Profile.Rules, or -1 for profile-level fields
|
||||
// (mode/final/rules count).
|
||||
type FieldError struct {
|
||||
Index int `json:"index"`
|
||||
Field string `json:"field"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// MaxRules is the upper bound on the number of rules a profile may hold.
|
||||
const MaxRules = 200
|
||||
|
||||
var validType = map[string]bool{"domain": true, "domain_suffix": true, "domain_keyword": true, "ip_cidr": true, "geoip": true, "geosite": true}
|
||||
var validAction = map[string]bool{"direct": true, "proxy": true, "reject": true}
|
||||
var geoWhitelist = map[string]bool{"cn": true} // geoip/geosite 仅自托管 cn
|
||||
|
||||
// Validate checks the profile against the type/action whitelist, CIDR
|
||||
// syntax, the geo set whitelist, and the rule count cap. It returns an empty
|
||||
// (nil) slice when the profile is valid; every violation is reported
|
||||
// independently (no short-circuiting) so callers can surface all errors at
|
||||
// once.
|
||||
func (p *Profile) Validate() []FieldError {
|
||||
var errs []FieldError
|
||||
if p.Mode != "rule" && p.Mode != "global" && p.Mode != "direct" {
|
||||
errs = append(errs, FieldError{-1, "mode", "must be rule|global|direct"})
|
||||
}
|
||||
if p.Final != "proxy" && p.Final != "direct" {
|
||||
errs = append(errs, FieldError{-1, "final", "must be proxy|direct"})
|
||||
}
|
||||
if len(p.Rules) > MaxRules {
|
||||
errs = append(errs, FieldError{-1, "rules", "exceeds max 200"})
|
||||
}
|
||||
for i, r := range p.Rules {
|
||||
if !validType[r.Type] {
|
||||
errs = append(errs, FieldError{i, "type", "invalid type"})
|
||||
}
|
||||
if !validAction[r.Action] {
|
||||
errs = append(errs, FieldError{i, "action", "invalid action"})
|
||||
}
|
||||
if r.Value == "" {
|
||||
errs = append(errs, FieldError{i, "value", "empty"})
|
||||
}
|
||||
switch r.Type {
|
||||
case "ip_cidr":
|
||||
if _, _, err := net.ParseCIDR(r.Value); err != nil {
|
||||
errs = append(errs, FieldError{i, "value", "invalid CIDR"})
|
||||
}
|
||||
case "geoip", "geosite":
|
||||
if !geoWhitelist[strings.ToLower(r.Value)] {
|
||||
errs = append(errs, FieldError{i, "value", "geo set not in whitelist (cn only)"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// Normalize trims rule values and de-duplicates rules by (type, value,
|
||||
// action), keeping the first occurrence's position (and its Note/Enabled).
|
||||
func (p *Profile) Normalize() {
|
||||
seen := map[string]bool{}
|
||||
out := p.Rules[:0]
|
||||
for _, r := range p.Rules {
|
||||
r.Value = strings.TrimSpace(r.Value)
|
||||
k := r.Type + "|" + r.Value + "|" + r.Action
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
p.Rules = out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package routing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
ok := Default()
|
||||
ok.Rules = []Rule{{Type: "domain_suffix", Value: "example.com", Action: "direct", Enabled: true}}
|
||||
if e := ok.Validate(); len(e) != 0 {
|
||||
t.Fatalf("valid profile got errors %v", e)
|
||||
}
|
||||
|
||||
bad := Default()
|
||||
bad.Mode = "weird" // 非法 mode
|
||||
bad.Final = "reject" // final 只能 proxy|direct
|
||||
bad.Rules = []Rule{
|
||||
{Type: "ip_cidr", Value: "not-a-cidr", Action: "proxy", Enabled: true}, // CIDR 非法
|
||||
{Type: "geosite", Value: "netflix", Action: "direct", Enabled: true}, // 不在白名单(仅 cn)
|
||||
{Type: "bogus", Value: "x", Action: "direct", Enabled: true}, // type 非法
|
||||
}
|
||||
errs := bad.Validate()
|
||||
if len(errs) < 5 {
|
||||
t.Fatalf("want >=5 field errors, got %d: %v", len(errs), errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCountLimit(t *testing.T) {
|
||||
p := Default()
|
||||
for i := 0; i < 201; i++ {
|
||||
p.Rules = append(p.Rules, Rule{Type: "domain", Value: "a.com", Action: "proxy", Enabled: true})
|
||||
}
|
||||
if e := p.Validate(); len(e) == 0 {
|
||||
t.Fatal("want count-limit error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDedupAndTrim(t *testing.T) {
|
||||
p := Default()
|
||||
p.Rules = []Rule{
|
||||
{Type: "domain", Value: " a.com ", Action: "proxy", Enabled: true},
|
||||
{Type: "domain", Value: "a.com", Action: "proxy", Enabled: false},
|
||||
{Type: "domain", Value: "b.com", Action: "proxy", Enabled: true},
|
||||
}
|
||||
p.Normalize()
|
||||
if len(p.Rules) != 2 {
|
||||
t.Fatalf("want 2 rules after dedup, got %d: %v", len(p.Rules), p.Rules)
|
||||
}
|
||||
if p.Rules[0].Value != "a.com" || p.Rules[1].Value != "b.com" {
|
||||
t.Fatalf("want first-occurrence order preserved, got %v", p.Rules)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user