filter.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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. normalized, err := normalizeExpression(trimmed)
  35. if err != nil {
  36. return nil, err
  37. }
  38. return compileNormalized(normalized)
  39. }
  40. func compileNormalized(normalized string) (*vm.Program, error) {
  41. return expr.Compile(normalized,
  42. expr.Env(Record{}),
  43. expr.AsBool(),
  44. expr.Function("includes", includes),
  45. expr.Function("hasTag", hasTag),
  46. )
  47. }
  48. func includes(params ...any) (any, error) {
  49. if len(params) < 2 {
  50. return nil, fmt.Errorf("includes expects 2 arguments, got %d", len(params))
  51. }
  52. haystack, ok := params[0].(string)
  53. if !ok {
  54. return nil, fmt.Errorf("expected string for haystack")
  55. }
  56. needle, ok := params[1].(string)
  57. if !ok {
  58. return nil, fmt.Errorf("expected string for needle")
  59. }
  60. return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)), nil
  61. }
  62. func hasTag(params ...any) (any, error) {
  63. if len(params) < 2 {
  64. return nil, fmt.Errorf("hasTag expects 2 arguments, got %d", len(params))
  65. }
  66. tags, ok := params[0].([]string)
  67. if !ok {
  68. return nil, fmt.Errorf("expected []string for tags")
  69. }
  70. needle, ok := params[1].(string)
  71. if !ok {
  72. return nil, fmt.Errorf("expected string for needle")
  73. }
  74. return tagListIncludes(tags, needle), nil
  75. }
  76. func tagListIncludes(tags []string, needle string) bool {
  77. needle = strings.ToLower(needle)
  78. for _, tag := range tags {
  79. if strings.Contains(strings.ToLower(tag), needle) {
  80. return true
  81. }
  82. }
  83. return false
  84. }
  85. // Matches evaluates a compiled filter against a log record.
  86. func Matches(program *vm.Program, record Record) (bool, error) {
  87. if program == nil {
  88. return true, nil
  89. }
  90. result, err := expr.Run(program, record)
  91. if err != nil {
  92. return false, err
  93. }
  94. matched, ok := result.(bool)
  95. if !ok {
  96. return false, fmt.Errorf("filter expression must return a boolean")
  97. }
  98. return matched, nil
  99. }
  100. func normalizeExpression(expression string) (string, error) {
  101. if isNegatedSearchTerm(expression) {
  102. term := quoteLiteral(strings.TrimPrefix(expression, "!"))
  103. return negatedSearchExpression(term), nil
  104. }
  105. if isPositiveSearchTerm(expression) {
  106. return positiveSearchExpression(quoteLiteral(expression)), nil
  107. }
  108. normalized := replaceContainsOperators(expression)
  109. normalized = replaceComparisons(normalized)
  110. return replaceBooleanWords(normalized), nil
  111. }
  112. func isNegatedSearchTerm(expression string) bool {
  113. if !strings.HasPrefix(expression, "!") {
  114. return false
  115. }
  116. remainder := strings.TrimSpace(expression[1:])
  117. return remainder != "" && !containsExpressionOperators(remainder)
  118. }
  119. func isPositiveSearchTerm(expression string) bool {
  120. return expression != "" && !containsExpressionOperators(expression)
  121. }
  122. func containsExpressionOperators(expression string) bool {
  123. lower := strings.ToLower(expression)
  124. operators := []string{"==", "!=", "&&", "||", " contains ", "(", ")"}
  125. for _, operator := range operators {
  126. if strings.Contains(lower, operator) {
  127. return true
  128. }
  129. }
  130. return false
  131. }
  132. func negatedSearchExpression(term string) string {
  133. return "!(" + positiveSearchExpression(term) + ")"
  134. }
  135. func positiveSearchExpression(term string) string {
  136. return "includes(Action, " + term + ") || includes(User, " + term + ") || includes(Status, " + term + ") || includes(Output, " + term + ") || hasTag(Tags, " + term + ")"
  137. }
  138. func replaceContainsOperators(expression string) string {
  139. return containsPattern.ReplaceAllStringFunc(expression, func(match string) string {
  140. parts := containsPattern.FindStringSubmatch(match)
  141. field := normalizeFieldName(parts[1])
  142. value := quoteIfNeeded(parts[2])
  143. return fmt.Sprintf("includes(%s, %s)", field, value)
  144. })
  145. }
  146. func replaceComparisons(expression string) string {
  147. return comparePattern.ReplaceAllStringFunc(expression, func(match string) string {
  148. parts := comparePattern.FindStringSubmatch(match)
  149. field := normalizeFieldName(parts[1])
  150. operator := parts[2]
  151. value := quoteIfNeeded(parts[3])
  152. return fmt.Sprintf("%s %s %s", field, operator, value)
  153. })
  154. }
  155. func normalizeFieldName(field string) string {
  156. if canonical, ok := fieldNameByLower[strings.ToLower(field)]; ok {
  157. return canonical
  158. }
  159. return field
  160. }
  161. func replaceBooleanWords(expression string) string {
  162. replacer := strings.NewReplacer(" and ", " && ", " AND ", " && ", " or ", " || ", " OR ", " || ")
  163. return replacer.Replace(expression)
  164. }
  165. func quoteIfNeeded(value string) string {
  166. if strings.HasPrefix(value, "\"") {
  167. return value
  168. }
  169. if isBooleanLiteral(value) || isIntegerLiteral(value) {
  170. return strings.ToLower(value)
  171. }
  172. return quoteLiteral(value)
  173. }
  174. func isBooleanLiteral(value string) bool {
  175. lower := strings.ToLower(value)
  176. return lower == "true" || lower == "false"
  177. }
  178. func isIntegerLiteral(value string) bool {
  179. _, err := strconv.ParseInt(value, 10, 64)
  180. return err == nil
  181. }
  182. func quoteLiteral(value string) string {
  183. return "\"" + strings.ReplaceAll(value, "\"", "\\\"") + "\""
  184. }