filter.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package logfilter
  2. import (
  3. "fmt"
  4. "regexp"
  5. "strconv"
  6. "strings"
  7. "github.com/expr-lang/expr"
  8. "github.com/expr-lang/expr/vm"
  9. )
  10. const maxFilterLength = 512
  11. var (
  12. comparePattern = regexp.MustCompile(`(?i)\b(Status|Action|User|ExitCode|Blocked|TimedOut|Running)\s*(==|!=)\s*("[^"]*"|\S+)`)
  13. containsPattern = regexp.MustCompile(`(?i)\b(Status|Action|User|Output)\s+contains\s+("[^"]*"|\S+)`)
  14. fieldNameByLower = map[string]string{
  15. "status": "Status",
  16. "action": "Action",
  17. "user": "User",
  18. "exitcode": "ExitCode",
  19. "blocked": "Blocked",
  20. "timedout": "TimedOut",
  21. "running": "Running",
  22. "output": "Output",
  23. }
  24. )
  25. // Compile parses and compiles a filter expression. Returns an error for invalid syntax.
  26. func Compile(expression string) (*vm.Program, error) {
  27. trimmed := strings.TrimSpace(expression)
  28. if trimmed == "" {
  29. return nil, nil
  30. }
  31. if len(trimmed) > maxFilterLength {
  32. return nil, fmt.Errorf("filter expression exceeds maximum length of %d characters", maxFilterLength)
  33. }
  34. return compileNormalized(normalizeExpression(trimmed))
  35. }
  36. func compileNormalized(normalized string) (*vm.Program, error) {
  37. return expr.Compile(normalized,
  38. expr.Env(Record{}),
  39. expr.AsBool(),
  40. expr.Function("includes", includes),
  41. expr.Function("hasTag", hasTag),
  42. )
  43. }
  44. func includes(params ...any) (any, error) {
  45. if len(params) < 2 {
  46. return nil, fmt.Errorf("includes expects 2 arguments, got %d", len(params))
  47. }
  48. haystack, ok := params[0].(string)
  49. if !ok {
  50. return nil, fmt.Errorf("expected string for haystack")
  51. }
  52. needle, ok := params[1].(string)
  53. if !ok {
  54. return nil, fmt.Errorf("expected string for needle")
  55. }
  56. return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)), nil
  57. }
  58. func hasTag(params ...any) (any, error) {
  59. if len(params) < 2 {
  60. return nil, fmt.Errorf("hasTag expects 2 arguments, got %d", len(params))
  61. }
  62. tags, ok := params[0].([]string)
  63. if !ok {
  64. return nil, fmt.Errorf("expected []string for tags")
  65. }
  66. needle, ok := params[1].(string)
  67. if !ok {
  68. return nil, fmt.Errorf("expected string for needle")
  69. }
  70. return tagListIncludes(tags, needle), nil
  71. }
  72. func tagListIncludes(tags []string, needle string) bool {
  73. needle = strings.ToLower(needle)
  74. for _, tag := range tags {
  75. if strings.Contains(strings.ToLower(tag), needle) {
  76. return true
  77. }
  78. }
  79. return false
  80. }
  81. // Matches evaluates a compiled filter against a log record.
  82. func Matches(program *vm.Program, record Record) (bool, error) {
  83. if program == nil {
  84. return true, nil
  85. }
  86. result, err := expr.Run(program, record)
  87. if err != nil {
  88. return false, err
  89. }
  90. matched, ok := result.(bool)
  91. if !ok {
  92. return false, fmt.Errorf("filter expression must return a boolean")
  93. }
  94. return matched, nil
  95. }
  96. func normalizeExpression(expression string) string {
  97. if isNegatedSearchTerm(expression) {
  98. term := quoteLiteral(strings.TrimPrefix(expression, "!"))
  99. return negatedSearchExpression(term)
  100. }
  101. if isPositiveSearchTerm(expression) {
  102. return positiveSearchExpression(quoteLiteral(expression))
  103. }
  104. normalized := replaceContainsOperators(expression)
  105. normalized = replaceComparisons(normalized)
  106. return replaceBooleanWords(normalized)
  107. }
  108. func isNegatedSearchTerm(expression string) bool {
  109. if !strings.HasPrefix(expression, "!") {
  110. return false
  111. }
  112. remainder := strings.TrimSpace(expression[1:])
  113. return remainder != "" && !containsExpressionOperators(remainder)
  114. }
  115. func isPositiveSearchTerm(expression string) bool {
  116. return expression != "" && !containsExpressionOperators(expression)
  117. }
  118. func containsExpressionOperators(expression string) bool {
  119. lower := strings.ToLower(expression)
  120. operators := []string{"==", "!=", "&&", "||", " contains ", "(", ")"}
  121. for _, operator := range operators {
  122. if strings.Contains(lower, operator) {
  123. return true
  124. }
  125. }
  126. return false
  127. }
  128. func negatedSearchExpression(term string) string {
  129. return "!(" + positiveSearchExpression(term) + ")"
  130. }
  131. func positiveSearchExpression(term string) string {
  132. return "includes(Action, " + term + ") || includes(User, " + term + ") || includes(Status, " + term + ") || includes(Output, " + term + ") || hasTag(Tags, " + term + ")"
  133. }
  134. func replaceContainsOperators(expression string) string {
  135. return containsPattern.ReplaceAllStringFunc(expression, func(match string) string {
  136. parts := containsPattern.FindStringSubmatch(match)
  137. field := normalizeFieldName(parts[1])
  138. value := quoteIfNeeded(parts[2])
  139. return fmt.Sprintf("includes(%s, %s)", field, value)
  140. })
  141. }
  142. func replaceComparisons(expression string) string {
  143. return comparePattern.ReplaceAllStringFunc(expression, func(match string) string {
  144. parts := comparePattern.FindStringSubmatch(match)
  145. field := normalizeFieldName(parts[1])
  146. operator := parts[2]
  147. value := quoteIfNeeded(parts[3])
  148. return fmt.Sprintf("%s %s %s", field, operator, value)
  149. })
  150. }
  151. func normalizeFieldName(field string) string {
  152. if canonical, ok := fieldNameByLower[strings.ToLower(field)]; ok {
  153. return canonical
  154. }
  155. return field
  156. }
  157. func replaceBooleanWords(expression string) string {
  158. replacer := strings.NewReplacer(" and ", " && ", " AND ", " && ", " or ", " || ", " OR ", " || ")
  159. return replacer.Replace(expression)
  160. }
  161. func quoteIfNeeded(value string) string {
  162. if strings.HasPrefix(value, "\"") {
  163. return value
  164. }
  165. if isBooleanLiteral(value) || isIntegerLiteral(value) {
  166. return strings.ToLower(value)
  167. }
  168. return quoteLiteral(value)
  169. }
  170. func isBooleanLiteral(value string) bool {
  171. lower := strings.ToLower(value)
  172. return lower == "true" || lower == "false"
  173. }
  174. func isIntegerLiteral(value string) bool {
  175. _, err := strconv.ParseInt(value, 10, 64)
  176. return err == nil
  177. }
  178. func quoteLiteral(value string) string {
  179. return "\"" + strings.ReplaceAll(value, "\"", "\\\"") + "\""
  180. }