Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions internal/storage/v2/api/tracestore/filter_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"reflect"
"regexp/syntax"
"slices"
"strings"

expression "github.com/jaegertracing/jaeger-idl/query/expression/v1"
)
Expand Down Expand Up @@ -437,6 +438,9 @@ func validatePattern(pattern string) error {
if err != nil {
return fmt.Errorf("operator %q takes a pattern in RE2 syntax: %w", expression.OpRegex, err)
}
if hasInlineCaseFolding(pattern) {
return fmt.Errorf("operator %q matches case-sensitively, so a pattern cannot use an inline case-folding flag", expression.OpRegex)
}
return checkPortable(parsed)
}

Expand All @@ -453,9 +457,6 @@ func checkPortable(re *syntax.Regexp) error {
if re.Flags&syntax.NonGreedy != 0 {
return fmt.Errorf("operator %q asks whether the value matches, so a quantifier cannot be lazy", expression.OpRegex)
}
if re.Flags&syntax.FoldCase != 0 {
return fmt.Errorf("operator %q matches case-sensitively, so a pattern cannot fold case", expression.OpRegex)
}
for _, sub := range re.Sub {
if err := checkPortable(sub); err != nil {
return err
Expand Down Expand Up @@ -536,3 +537,80 @@ func termName(e expression.Expression) string {
return "an unknown term"
}
}

// hasInlineCaseFolding reports whether a pattern contains an unescaped inline
// case-folding flag, e.g. (?i) or (?i:...).
//
// The check is lexical because Go's regexp parser folds two-element case
// classes like [aA] or (a|A) into a FoldCase AST node, destroying the
// distinction between a case class (which is portable and supported by
// storage engines) and an inline flag group (which backends like Elasticsearch
// cannot honor).
func hasInlineCaseFolding(pattern string) bool {
inClass := false
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '\\':
i = skipEscape(pattern, i)
case '[':
inClass = true
case ']':
inClass = false
case '(':
if inClass {
continue
}
folds, last := groupFoldsCase(pattern, i)
if folds {
return true
}
i = last
default:
}
}
return false
}

// skipEscape returns the index of the last byte consumed by the escape that
// starts at pattern[i]: the escaped character, or the whole of a \Q...\E
// quoted run, which extends to the end of the pattern when \E is absent.
func skipEscape(pattern string, i int) int {
if i+1 >= len(pattern) {
return i
}
if pattern[i+1] != 'Q' {
return i + 1
}
end := strings.Index(pattern[i+2:], `\E`)
if end < 0 {
return len(pattern)
}
return i + 2 + end + 1
}

// groupFoldsCase reports whether the group opening at pattern[i] is a flag
// group that switches case folding on, such as (?i) or (?i:...), and returns
// the index of the last byte it examined. A named group (?P<n>...) and a flag
// that only appears after a minus, such as (?-i), do not fold case.
func groupFoldsCase(pattern string, i int) (bool, int) {
if i+1 >= len(pattern) || pattern[i+1] != '?' {
return false, i
}
j := i + 2
if j < len(pattern) && pattern[j] == 'P' {
return false, j
}
negated := false
for ; j < len(pattern) && pattern[j] != ':' && pattern[j] != ')'; j++ {
switch pattern[j] {
case '-':
negated = true
case 'i', 'I':
if !negated {
return true, j
}
default:
}
}
return false, j
}
61 changes: 60 additions & 1 deletion internal/storage/v2/api/tracestore/filter_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,30 @@ func TestValidateFilter_Accepts(t *testing.T) {
&expression.StringValue{Value: "/api/.*"},
}},
},
{
name: "a regular expression spelling case-insensitivity as a character class",
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: "[gG]et"},
}},
},
{
name: "a regular expression spelling case-insensitivity as an alternation",
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: "(g|G)et"},
}},
},
{
name: "a regular expression quoting what looks like a case-folding flag",
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: `\Q(?i)\Eget`},
}},
},
{
name: "a regular expression switching case folding off",
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: "(?-i)get"},
}},
},
{
name: "an ordered comparison of typed constants",
filter: &expression.Call{Op: expression.OpGte, Args: []expression.Expression{
Expand Down Expand Up @@ -445,11 +469,32 @@ func TestValidateFilter_Rejects(t *testing.T) {
},
{
name: "a regular expression asking to fold case",
expectedErr: `operator "regex" matches case-sensitively, so a pattern cannot fold case`,
expectedErr: `operator "regex" matches case-sensitively, so a pattern cannot use an inline case-folding flag`,
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: "(?i)get"},
}},
},
{
name: "a regular expression folding case inside a group",
expectedErr: `operator "regex" matches case-sensitively, so a pattern cannot use an inline case-folding flag`,
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: "(?i:get)"},
}},
},
{
name: "a regular expression folding case from the middle of the pattern",
expectedErr: `operator "regex" matches case-sensitively, so a pattern cannot use an inline case-folding flag`,
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: "g(?i)et"},
}},
},
{
name: "a regular expression folding case among other flags",
expectedErr: `operator "regex" matches case-sensitively, so a pattern cannot use an inline case-folding flag`,
filter: &expression.Call{Op: expression.OpRegex, Args: []expression.Expression{
&expression.FieldRef{Name: expression.SpanFieldName, Level: expression.LevelSpan}, &expression.StringValue{Value: "(?is)get"},
}},
},
{
name: "a regular expression over a duration field",
expectedErr: `operator "regex" matches text, and span.duration holds a duration`,
Expand Down Expand Up @@ -981,3 +1026,17 @@ func TestValidateFilter_RefusesAnUnknownTerm(t *testing.T) {
err := ValidateFilter(eq(attr("a"), term))
require.ErrorContains(t, err, "an unknown term")
}

func TestHasInlineCaseFolding(t *testing.T) {
folding := []string{"(?i)get", "(?i:get)", "g(?i)et", "(?is)get", "(?i-s)get", "(?si)get"}
for _, pattern := range folding {
assert.True(t, hasInlineCaseFolding(pattern), pattern)
}
notFolding := []string{
"get", "[gG]et", "(g|G)et", "[(?i)]", `\(?i\)`, `\Q(?i)\Eget`, `\Q(?i)`,
"(?-i)get", "(?-si)get", "(?s)get", "(?P<n>get)", "(?:get)", "(", "(?",
}
for _, pattern := range notFolding {
assert.False(t, hasInlineCaseFolding(pattern), pattern)
}
}
Loading