diff --git a/routing/matcher.go b/routing/matcher.go index d9e325e1..967a8155 100644 --- a/routing/matcher.go +++ b/routing/matcher.go @@ -38,6 +38,10 @@ type matcher struct { tree matchersTree } +// NewMatcher parses a routing rule string and returns a routing.Matcher. +// The rule syntax supports boolean expressions with matchers like +// Host(`example.com`), Path(`/api`), Method(`GET`), etc., combined +// with &&, ||, and ! operators. func NewMatcher(rule string) (routing.Matcher, error) { parse, err := defaultParser.Parse(rule) if err != nil { @@ -239,7 +243,7 @@ func host(tree *matchersTree, hosts ...string) error { return true } - if host[0] == '.' && strings.HasSuffix(reqHost, host[1:]) { + if host[0] == '.' && strings.HasSuffix(reqHost, host) { return true } @@ -288,7 +292,7 @@ func pathRegexp(tree *matchersTree, paths ...string) error { re, err := regexp.Compile(path) if err != nil { - return fmt.Errorf("compiling PathPrefix matcher: %w", err) + return fmt.Errorf("compiling PathRegexp matcher: %w", err) } tree.matcher = func(req *routing.Request) bool { diff --git a/routing/matcher_test.go b/routing/matcher_test.go new file mode 100644 index 00000000..cb0d48ec --- /dev/null +++ b/routing/matcher_test.go @@ -0,0 +1,653 @@ +package routing + +import ( + "net" + "net/http" + "net/url" + "testing" + + "github.com/go-gost/core/routing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsASCII(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"", true}, + {"hello", true}, + {"Hello World 123!", true}, + {"/path/to/resource", true}, + {"café", false}, + {"日本語", false}, + {"\xff", false}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.want, IsASCII(tc.input)) + }) + } +} + +func TestParseHost(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"example.com", "example.com"}, + {"example.com:8080", "example.com"}, + {"192.168.1.1", "192.168.1.1"}, + {"192.168.1.1:443", "192.168.1.1"}, + {"[::1]:8080", "::1"}, + {"[fe80::1]:443", "fe80::1"}, + {"[::1]", "::1"}, + {"", ""}, + {":8080", ""}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.want, parseHost(tc.input)) + }) + } +} + +func TestMatcherClientIP(t *testing.T) { + m, err := NewMatcher(`ClientIP("192.168.1.1")`) + require.NoError(t, err) + + tests := []struct { + desc string + req *routing.Request + want bool + }{ + { + desc: "exact match", + req: &routing.Request{ClientIP: net.ParseIP("192.168.1.1")}, + want: true, + }, + { + desc: "no match", + req: &routing.Request{ClientIP: net.ParseIP("10.0.0.1")}, + want: false, + }, + { + desc: "nil ClientIP", + req: &routing.Request{ClientIP: nil}, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + assert.Equal(t, tc.want, m.Match(tc.req)) + }) + } +} + +func TestMatcherClientIP_CIDR(t *testing.T) { + m, err := NewMatcher(`ClientIP("192.168.1.0/24")`) + require.NoError(t, err) + + tests := []struct { + desc string + ip string + want bool + }{ + {"in range", "192.168.1.50", true}, + {"network addr", "192.168.1.0", true}, + {"broadcast", "192.168.1.255", true}, + {"outside range", "192.168.2.1", false}, + {"different class", "10.0.0.1", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{ClientIP: net.ParseIP(tc.ip)} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherClientIP_Invalid(t *testing.T) { + _, err := NewMatcher(`ClientIP("not-an-ip")`) + assert.Error(t, err) +} + +func TestMatcherProto(t *testing.T) { + m, err := NewMatcher(`Proto("http")`) + require.NoError(t, err) + + tests := []struct { + desc string + proto string + want bool + }{ + {"lowercase match", "http", true}, + {"uppercase no match", "HTTP", false}, + {"wrong proto", "socks5", false}, + {"empty", "", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Protocol: tc.proto} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherMethod(t *testing.T) { + m, err := NewMatcher(`Method("GET")`) + require.NoError(t, err) + + tests := []struct { + desc string + method string + want bool + }{ + {"exact match", "GET", true}, + {"case mismatch", "get", false}, + {"wrong method", "POST", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Method: tc.method} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherPath(t *testing.T) { + m, err := NewMatcher(`Path("/api/v1")`) + require.NoError(t, err) + + tests := []struct { + desc string + path string + want bool + }{ + {"exact match", "/api/v1", true}, + {"prefix only", "/api/v1/users", false}, + {"no match", "/api/v2", false}, + {"empty", "", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Path: tc.path} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherPath_NoLeadingSlash(t *testing.T) { + _, err := NewMatcher(`Path("api")`) + assert.Error(t, err) +} + +func TestMatcherPathPrefix(t *testing.T) { + m, err := NewMatcher(`PathPrefix("/api")`) + require.NoError(t, err) + + tests := []struct { + desc string + path string + want bool + }{ + {"exact match", "/api", true}, + {"prefix match", "/api/v1/users", true}, + {"no match", "/other", false}, + {"partial no match", "/apifoo", true}, // HasPrefix matches this + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Path: tc.path} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherPathPrefix_NoLeadingSlash(t *testing.T) { + _, err := NewMatcher(`PathPrefix("api")`) + assert.Error(t, err) +} + +func TestMatcherPathRegexp(t *testing.T) { + m, err := NewMatcher(`PathRegexp("^/api/v[0-9]+")`) + require.NoError(t, err) + + tests := []struct { + desc string + path string + want bool + }{ + {"match v1", "/api/v1/users", true}, + {"match v2", "/api/v2", true}, + {"no version", "/api/users", false}, + {"wrong path", "/other", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Path: tc.path} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherPathRegexp_Invalid(t *testing.T) { + _, err := NewMatcher(`PathRegexp("[invalid")`) + assert.Error(t, err) +} + +func TestMatcherHost(t *testing.T) { + m, err := NewMatcher(`Host("example.com")`) + require.NoError(t, err) + + tests := []struct { + desc string + host string + want bool + }{ + {"exact match", "example.com", true}, + {"case insensitive", "EXAMPLE.COM", true}, + {"subdomain no match", "foo.example.com", false}, + {"with port", "example.com:8080", true}, + {"no match", "other.com", false}, + {"empty", "", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Host: tc.host} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherHost_Wildcard(t *testing.T) { + m, err := NewMatcher(`Host("*.example.com")`) + require.NoError(t, err) + + tests := []struct { + desc string + host string + want bool + }{ + {"subdomain match", "foo.example.com", true}, + {"deep subdomain", "a.b.example.com", true}, + {"bare domain no match", "example.com", false}, + {"false suffix no match", "xexample.com", false}, + {"subdomain with port", "foo.example.com:8080", true}, + {"empty", "", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Host: tc.host} + assert.Equal(t, tc.want, m.Match(req), tc.desc) + }) + } +} + +func TestMatcherHost_NonASCII(t *testing.T) { + _, err := NewMatcher(`Host("example.cóm")`) + assert.Error(t, err) +} + +func TestMatcherHostRegexp(t *testing.T) { + m, err := NewMatcher(`HostRegexp(".*\\.example\\.com")`) + require.NoError(t, err) + + tests := []struct { + desc string + host string + want bool + }{ + {"subdomain", "foo.example.com", true}, + {"bare domain", "example.com", false}, + {"no match", "other.com", false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Host: tc.host} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherHostRegexp_Invalid(t *testing.T) { + _, err := NewMatcher(`HostRegexp("[invalid")`) + assert.Error(t, err) +} + +func TestMatcherHostRegexp_NonASCII(t *testing.T) { + _, err := NewMatcher(`HostRegexp("example.cóm")`) + assert.Error(t, err) +} + +func TestMatcherHeader(t *testing.T) { + m, err := NewMatcher(`Header("X-Custom")`) + require.NoError(t, err) + + tests := []struct { + desc string + header http.Header + want bool + }{ + {"present", http.Header{"X-Custom": {"value"}}, true}, + {"absent", http.Header{"Other": {"value"}}, false}, + {"nil header", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Header: tc.header} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherHeaderWithValue(t *testing.T) { + m, err := NewMatcher(`Header("X-Custom", "secret")`) + require.NoError(t, err) + + tests := []struct { + desc string + header http.Header + want bool + }{ + {"matching value", http.Header{"X-Custom": {"secret"}}, true}, + {"wrong value", http.Header{"X-Custom": {"other"}}, false}, + {"multiple values", http.Header{"X-Custom": {"other", "secret"}}, true}, + {"absent", http.Header{}, false}, + {"nil", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Header: tc.header} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherHeaderRegexp(t *testing.T) { + m, err := NewMatcher(`HeaderRegexp("X-Custom", "^v[0-9]+$")`) + require.NoError(t, err) + + tests := []struct { + desc string + header http.Header + want bool + }{ + {"match v1", http.Header{"X-Custom": {"v1"}}, true}, + {"match v12", http.Header{"X-Custom": {"v12"}}, true}, + {"no match", http.Header{"X-Custom": {"invalid"}}, false}, + {"nil", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Header: tc.header} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherHeaderRegexp_KeyOnly(t *testing.T) { + // With 1 param, HeaderRegexp delegates to Header (key existence check) + m, err := NewMatcher(`HeaderRegexp("X-Custom")`) + require.NoError(t, err) + + req := &routing.Request{Header: http.Header{"X-Custom": {"anything"}}} + assert.True(t, m.Match(req)) + + req = &routing.Request{Header: http.Header{}} + assert.False(t, m.Match(req)) +} + +func TestMatcherHeaderRegexp_Invalid(t *testing.T) { + _, err := NewMatcher(`HeaderRegexp("X-Custom", "[invalid")`) + assert.Error(t, err) +} + +func TestMatcherQuery(t *testing.T) { + m, err := NewMatcher(`Query("page")`) + require.NoError(t, err) + + tests := []struct { + desc string + query url.Values + want bool + }{ + {"present", url.Values{"page": {"1"}}, true}, + {"absent", url.Values{"other": {"1"}}, false}, + {"nil", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Query: tc.query} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherQueryWithValue(t *testing.T) { + m, err := NewMatcher(`Query("page", "2")`) + require.NoError(t, err) + + tests := []struct { + desc string + query url.Values + want bool + }{ + {"matching value", url.Values{"page": {"2"}}, true}, + {"wrong value", url.Values{"page": {"1"}}, false}, + {"multi-value", url.Values{"page": {"1", "2"}}, true}, + {"absent", url.Values{}, false}, + {"nil", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Query: tc.query} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherQueryRegexp(t *testing.T) { + m, err := NewMatcher(`QueryRegexp("page", "^[0-9]+$")`) + require.NoError(t, err) + + tests := []struct { + desc string + query url.Values + want bool + }{ + {"numeric", url.Values{"page": {"42"}}, true}, + {"non-numeric", url.Values{"page": {"abc"}}, false}, + {"nil", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + req := &routing.Request{Query: tc.query} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} + +func TestMatcherQueryRegexp_KeyOnly(t *testing.T) { + m, err := NewMatcher(`QueryRegexp("page")`) + require.NoError(t, err) + + req := &routing.Request{Query: url.Values{"page": {"1"}}} + assert.True(t, m.Match(req)) + + req = &routing.Request{Query: url.Values{}} + assert.False(t, m.Match(req)) +} + +func TestMatcherQueryRegexp_Invalid(t *testing.T) { + _, err := NewMatcher(`QueryRegexp("page", "[invalid")`) + assert.Error(t, err) +} + +func TestMatcherBooleanAnd(t *testing.T) { + m, err := NewMatcher(`Host("example.com") && Path("/api")`) + require.NoError(t, err) + + req := &routing.Request{Host: "example.com", Path: "/api"} + assert.True(t, m.Match(req)) + + req = &routing.Request{Host: "example.com", Path: "/other"} + assert.False(t, m.Match(req)) + + req = &routing.Request{Host: "other.com", Path: "/api"} + assert.False(t, m.Match(req)) +} + +func TestMatcherBooleanOr(t *testing.T) { + m, err := NewMatcher(`Host("a.com") || Host("b.com")`) + require.NoError(t, err) + + req := &routing.Request{Host: "a.com"} + assert.True(t, m.Match(req)) + + req = &routing.Request{Host: "b.com"} + assert.True(t, m.Match(req)) + + req = &routing.Request{Host: "c.com"} + assert.False(t, m.Match(req)) +} + +func TestMatcherBooleanNot(t *testing.T) { + m, err := NewMatcher(`!Host("example.com")`) + require.NoError(t, err) + + req := &routing.Request{Host: "other.com"} + assert.True(t, m.Match(req)) + + req = &routing.Request{Host: "example.com"} + assert.False(t, m.Match(req)) +} + +func TestMatcherBooleanComplex(t *testing.T) { + // (Host a.com OR Host b.com) AND Path /api + m, err := NewMatcher(`(Host("a.com") || Host("b.com")) && PathPrefix("/api")`) + require.NoError(t, err) + + req := &routing.Request{Host: "a.com", Path: "/api/v1"} + assert.True(t, m.Match(req)) + + req = &routing.Request{Host: "c.com", Path: "/api/v1"} + assert.False(t, m.Match(req)) + + req = &routing.Request{Host: "a.com", Path: "/other"} + assert.False(t, m.Match(req)) +} + +func TestMatcherBooleanDeMorgan(t *testing.T) { + // !(Host a.com && Host b.com) => !Host a.com || !Host b.com + m, err := NewMatcher(`!(Host("a.com") && Path("/secret"))`) + require.NoError(t, err) + + // Both match → AND is true → NOT inverts to false + req := &routing.Request{Host: "a.com", Path: "/secret"} + assert.False(t, m.Match(req)) + + // Only host matches → AND is false → NOT inverts to true + req = &routing.Request{Host: "a.com", Path: "/public"} + assert.True(t, m.Match(req)) + + // Neither matches → AND is false → NOT inverts to true + req = &routing.Request{Host: "b.com", Path: "/public"} + assert.True(t, m.Match(req)) +} + +func TestMatcherInvalidRules(t *testing.T) { + tests := []struct { + desc string + rule string + }{ + {"empty", ""}, + {"unknown matcher", `Unknown("value")`}, + {"missing close paren", `Host("example.com"`}, + {"trailing and", `Host("a.com") &&`}, + {"trailing or", `Host("a.com") ||`}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + _, err := NewMatcher(tc.rule) + assert.Error(t, err, "expected error for rule: %s", tc.rule) + }) + } +} + +func TestMatcherNilReceiver(t *testing.T) { + var m *matcher + assert.False(t, m.Match(&routing.Request{})) +} + +func TestMatcherParameterCount(t *testing.T) { + tests := []struct { + desc string + rule string + ok bool + }{ + {"Host 0 params", `Host()`, false}, + {"Host 1 param", `Host("a.com")`, true}, + {"Host 2 params", `Host("a.com", "b.com")`, false}, + {"Header 1 param", `Header("X-Foo")`, true}, + {"Header 2 params", `Header("X-Foo", "bar")`, true}, + {"Header 3 params", `Header("X-Foo", "bar", "baz")`, false}, + {"Query 1 param", `Query("key")`, true}, + {"Query 2 params", `Query("key", "val")`, true}, + {"ClientIP 0 params", `ClientIP()`, false}, + {"Path 0 params", `Path()`, false}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + _, err := NewMatcher(tc.rule) + if tc.ok { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +func TestMatcherCaseInsensitiveMatcherName(t *testing.T) { + tests := []struct { + desc string + rule string + host string + want bool + }{ + {"lowercase", `host("example.com")`, "example.com", true}, + {"uppercase", `HOST("example.com")`, "example.com", true}, + {"title case", `Host("example.com")`, "example.com", true}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + m, err := NewMatcher(tc.rule) + require.NoError(t, err) + req := &routing.Request{Host: tc.host} + assert.Equal(t, tc.want, m.Match(req)) + }) + } +} diff --git a/routing/rules/parser.go b/routing/rules/parser.go index 50fcfe40..29468eb5 100644 --- a/routing/rules/parser.go +++ b/routing/rules/parser.go @@ -14,12 +14,15 @@ const ( or = "or" ) +// Parser is an alias for the predicate parser used to parse routing rules. type Parser = predicate.Parser // TreeBuilder defines the type for a Tree builder. type TreeBuilder func() *Tree -// Tree represents the rules' tree structure. +// Tree represents the rules' tree structure used for routing matchers. +// Leaf nodes hold a Matcher name, optional negation, and string values. +// Internal nodes hold an "and"/"or" operator with left and right children. type Tree struct { Matcher string Not bool