| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216 |
- package logfilter
- import (
- "fmt"
- "regexp"
- "strconv"
- "strings"
- "github.com/expr-lang/expr"
- "github.com/expr-lang/expr/vm"
- )
- const maxFilterLength = 512
- var (
- comparePattern = regexp.MustCompile(`(?i)\b(Status|Action|User|ExitCode|Blocked|TimedOut|Running)\s*(==|!=)\s*("[^"]*"|\S+)`)
- containsPattern = regexp.MustCompile(`(?i)\b(Status|Action|User|Output)\s+contains\s+("[^"]*"|\S+)`)
- fieldNameByLower = map[string]string{
- "status": "Status",
- "action": "Action",
- "user": "User",
- "exitcode": "ExitCode",
- "blocked": "Blocked",
- "timedout": "TimedOut",
- "running": "Running",
- "output": "Output",
- }
- )
- // Compile parses and compiles a filter expression. Returns an error for invalid syntax.
- func Compile(expression string) (*vm.Program, error) {
- trimmed := strings.TrimSpace(expression)
- if trimmed == "" {
- return nil, nil
- }
- if len(trimmed) > maxFilterLength {
- return nil, fmt.Errorf("filter expression exceeds maximum length of %d characters", maxFilterLength)
- }
- normalized, err := normalizeExpression(trimmed)
- if err != nil {
- return nil, err
- }
- return compileNormalized(normalized)
- }
- func compileNormalized(normalized string) (*vm.Program, error) {
- return expr.Compile(normalized,
- expr.Env(Record{}),
- expr.AsBool(),
- expr.Function("includes", includes),
- expr.Function("hasTag", hasTag),
- )
- }
- func includes(params ...any) (any, error) {
- if len(params) < 2 {
- return nil, fmt.Errorf("includes expects 2 arguments, got %d", len(params))
- }
- haystack, ok := params[0].(string)
- if !ok {
- return nil, fmt.Errorf("expected string for haystack")
- }
- needle, ok := params[1].(string)
- if !ok {
- return nil, fmt.Errorf("expected string for needle")
- }
- return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)), nil
- }
- func hasTag(params ...any) (any, error) {
- if len(params) < 2 {
- return nil, fmt.Errorf("hasTag expects 2 arguments, got %d", len(params))
- }
- tags, ok := params[0].([]string)
- if !ok {
- return nil, fmt.Errorf("expected []string for tags")
- }
- needle, ok := params[1].(string)
- if !ok {
- return nil, fmt.Errorf("expected string for needle")
- }
- return tagListIncludes(tags, needle), nil
- }
- func tagListIncludes(tags []string, needle string) bool {
- needle = strings.ToLower(needle)
- for _, tag := range tags {
- if strings.Contains(strings.ToLower(tag), needle) {
- return true
- }
- }
- return false
- }
- // Matches evaluates a compiled filter against a log record.
- func Matches(program *vm.Program, record Record) (bool, error) {
- if program == nil {
- return true, nil
- }
- result, err := expr.Run(program, record)
- if err != nil {
- return false, err
- }
- matched, ok := result.(bool)
- if !ok {
- return false, fmt.Errorf("filter expression must return a boolean")
- }
- return matched, nil
- }
- func normalizeExpression(expression string) (string, error) {
- if isNegatedSearchTerm(expression) {
- term := quoteLiteral(strings.TrimPrefix(expression, "!"))
- return negatedSearchExpression(term), nil
- }
- if isPositiveSearchTerm(expression) {
- return positiveSearchExpression(quoteLiteral(expression)), nil
- }
- normalized := replaceContainsOperators(expression)
- normalized = replaceComparisons(normalized)
- return replaceBooleanWords(normalized), nil
- }
- func isNegatedSearchTerm(expression string) bool {
- if !strings.HasPrefix(expression, "!") {
- return false
- }
- remainder := strings.TrimSpace(expression[1:])
- return remainder != "" && !containsExpressionOperators(remainder)
- }
- func isPositiveSearchTerm(expression string) bool {
- return expression != "" && !containsExpressionOperators(expression)
- }
- func containsExpressionOperators(expression string) bool {
- lower := strings.ToLower(expression)
- operators := []string{"==", "!=", "&&", "||", " contains ", "(", ")"}
- for _, operator := range operators {
- if strings.Contains(lower, operator) {
- return true
- }
- }
- return false
- }
- func negatedSearchExpression(term string) string {
- return "!(" + positiveSearchExpression(term) + ")"
- }
- func positiveSearchExpression(term string) string {
- return "includes(Action, " + term + ") || includes(User, " + term + ") || includes(Status, " + term + ") || includes(Output, " + term + ") || hasTag(Tags, " + term + ")"
- }
- func replaceContainsOperators(expression string) string {
- return containsPattern.ReplaceAllStringFunc(expression, func(match string) string {
- parts := containsPattern.FindStringSubmatch(match)
- field := normalizeFieldName(parts[1])
- value := quoteIfNeeded(parts[2])
- return fmt.Sprintf("includes(%s, %s)", field, value)
- })
- }
- func replaceComparisons(expression string) string {
- return comparePattern.ReplaceAllStringFunc(expression, func(match string) string {
- parts := comparePattern.FindStringSubmatch(match)
- field := normalizeFieldName(parts[1])
- operator := parts[2]
- value := quoteIfNeeded(parts[3])
- return fmt.Sprintf("%s %s %s", field, operator, value)
- })
- }
- func normalizeFieldName(field string) string {
- if canonical, ok := fieldNameByLower[strings.ToLower(field)]; ok {
- return canonical
- }
- return field
- }
- func replaceBooleanWords(expression string) string {
- replacer := strings.NewReplacer(" and ", " && ", " AND ", " && ", " or ", " || ", " OR ", " || ")
- return replacer.Replace(expression)
- }
- func quoteIfNeeded(value string) string {
- if strings.HasPrefix(value, "\"") {
- return value
- }
- if isBooleanLiteral(value) || isIntegerLiteral(value) {
- return strings.ToLower(value)
- }
- return quoteLiteral(value)
- }
- func isBooleanLiteral(value string) bool {
- lower := strings.ToLower(value)
- return lower == "true" || lower == "false"
- }
- func isIntegerLiteral(value string) bool {
- _, err := strconv.ParseInt(value, 10, 64)
- return err == nil
- }
- func quoteLiteral(value string) string {
- return "\"" + strings.ReplaceAll(value, "\"", "\\\"") + "\""
- }
|