diff --git a/pkg/match/doc.go b/pkg/match/doc.go new file mode 100644 index 0000000..206454c --- /dev/null +++ b/pkg/match/doc.go @@ -0,0 +1,9 @@ +// Package match implements the simple glob matching used by routing rules to +// compare namespaces and metadata values. +// +// A pattern is one of four forms: a literal (exact match), a prefix glob +// ("foo*", starts-with), a suffix glob ("*foo", ends-with), or a contains glob +// ("*foo*"). A pattern consisting only of '*' (for example "*", "**") matches +// anything. A "*" anywhere other than the leading or trailing position is +// rejected at compile time. +package match diff --git a/pkg/match/match.go b/pkg/match/match.go new file mode 100644 index 0000000..7ed6e32 --- /dev/null +++ b/pkg/match/match.go @@ -0,0 +1,84 @@ +package match + +import ( + "fmt" + "strings" +) + +const ( + literal kind = iota + prefix kind = iota + suffix kind = iota + contains kind = iota + all kind = iota +) + +type ( + // Matcher is a compiled glob pattern. The zero value matches only the empty + // string. Construct one with [Compile]. + Matcher struct { + kind kind + text string + } + + kind int +) + +// Compile parses pattern into a [Matcher]. See the package docs for the +// supported forms. +func Compile(pattern string) (Matcher, error) { + leading := strings.HasPrefix(pattern, "*") + trailing := strings.HasSuffix(pattern, "*") + + var m Matcher + switch { + case len(pattern) > 0 && strings.Count(pattern, "*") == len(pattern): + return Matcher{kind: all}, nil + case leading && trailing: + m = Matcher{kind: contains, text: pattern[1 : len(pattern)-1]} + case leading: + m = Matcher{kind: suffix, text: strings.TrimPrefix(pattern, "*")} + case trailing: + m = Matcher{kind: prefix, text: strings.TrimSuffix(pattern, "*")} + default: + m = Matcher{kind: literal, text: pattern} + } + + // The literal portion must be free of wildcards; a "*" here means the + // pattern had one in an unsupported interior position (e.g. "a*b"). + if strings.Contains(m.text, "*") { + return Matcher{}, fmt.Errorf("match: %q has a '*' outside the leading or trailing position", pattern) + } + + return m, nil +} + +// MustCompile is like [Compile] but panics if pattern is invalid. It is meant +// for package-level variables and tests where an invalid pattern is a +// programming error. +func MustCompile(pattern string) Matcher { + m, err := Compile(pattern) + if err != nil { + panic(err) + } + + return m +} + +// Match reports whether s satisfies the compiled pattern. +func (m Matcher) Match(s string) bool { + switch m.kind { + case literal: + return s == m.text + case prefix: + return strings.HasPrefix(s, m.text) + case suffix: + return strings.HasSuffix(s, m.text) + case contains: + return strings.Contains(s, m.text) + case all: + return true + default: + return false + } +} diff --git a/pkg/match/match_test.go b/pkg/match/match_test.go new file mode 100644 index 0000000..dbff057 --- /dev/null +++ b/pkg/match/match_test.go @@ -0,0 +1,93 @@ +package match_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/temporalio/temporal-proxy/pkg/match" +) + +func TestMatcher(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + matches []string + misses []string + }{ + { + name: "literal", + pattern: "foo", + matches: []string{"foo"}, + misses: []string{"foobar", "barfoo", ""}, + }, + { + name: "prefix", + pattern: "foo*", + matches: []string{"foo", "foobar"}, + misses: []string{"barfoo", ""}, + }, + { + name: "suffix", + pattern: "*foo", + matches: []string{"foo", "barfoo"}, + misses: []string{"foobar", ""}, + }, + { + name: "contains", + pattern: "*-test-*", + matches: []string{"prod-test-1", "-test-"}, + misses: []string{"test", "prod-1", ""}, + }, + { + name: "bare star matches anything", + pattern: "*", + matches: []string{"anything", ""}, + }, + { + name: "all stars matches anything", + pattern: "**", + matches: []string{"anything", ""}, + }, + { + name: "empty pattern is literal empty", + pattern: "", + matches: []string{""}, + misses: []string{"foo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + m, err := match.Compile(tt.pattern) + require.NoError(t, err) + + for _, s := range tt.matches { + require.Truef(t, m.Match(s), "pattern %q should match %q", tt.pattern, s) + } + for _, s := range tt.misses { + require.Falsef(t, m.Match(s), "pattern %q should not match %q", tt.pattern, s) + } + }) + } +} + +func TestCompileEmbeddedStar(t *testing.T) { + t.Parallel() + + for _, pattern := range []string{"a*b", "*a*b", "a*b*", "a*b*c"} { + _, err := match.Compile(pattern) + require.Errorf(t, err, "pattern %q should be rejected", pattern) + } +} + +func TestMustCompile(t *testing.T) { + t.Parallel() + + require.NotPanics(t, func() { match.MustCompile("foo*") }) + require.Panics(t, func() { match.MustCompile("a*b") }) +}