Files
Mellaris/ruleset/expr.go
T
hayzam 7a3f6e945d Improves flow handling and adds runtime stats APIs
Refactors TCP and UDP flow managers to enhance analyzer selection and flow binding accuracy, including O(1) UDP stream rebinding by 5-tuple.
Introduces runtime stats tracking for engine and ruleset operations, exposing new APIs for granular performance and error metrics.
Optimizes GeoMatcher with result caching and supports efficient geosite set matching, reducing redundant computation in ruleset expressions.
2026-05-13 06:10:38 +05:30

876 lines
22 KiB
Go

package ruleset
import (
"context"
"fmt"
"net"
"os"
"reflect"
"strings"
"sync"
"time"
"github.com/expr-lang/expr/builtin"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/ast"
"github.com/expr-lang/expr/conf"
"github.com/expr-lang/expr/parser"
"github.com/expr-lang/expr/vm"
"gopkg.in/yaml.v3"
"git.difuse.io/Difuse/Mellaris/analyzer"
"git.difuse.io/Difuse/Mellaris/modifier"
"git.difuse.io/Difuse/Mellaris/ruleset/builtins"
"git.difuse.io/Difuse/Mellaris/ruleset/builtins/geo"
)
// ExprRule is the external representation of an expression rule.
type ExprRule struct {
Name string `yaml:"name"`
Action string `yaml:"action"`
Log bool `yaml:"log"`
Modifier ModifierEntry `yaml:"modifier"`
Expr string `yaml:"expr"`
StartTime string `yaml:"start_time"`
StopTime string `yaml:"stop_time"`
Weekdays []string `yaml:"weekdays"`
}
type ModifierEntry struct {
Name string `yaml:"name"`
Args map[string]interface{} `yaml:"args"`
}
func ExprRulesFromYAML(file string) ([]ExprRule, error) {
bs, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var rules []ExprRule
err = yaml.Unmarshal(bs, &rules)
return rules, err
}
// compiledExprRule is the internal, compiled representation of an expression rule.
type compiledExprRule struct {
Name string
Action *Action // fallthrough if nil
Log bool
ModInstance modifier.Instance
Program *vm.Program
GeoSiteConditions []string
StartTimeSecs int // seconds since midnight, -1 if unset
StopTimeSecs int // seconds since midnight, -1 if unset
Weekdays []time.Weekday
WeekdaysNegated bool
}
var _ Ruleset = (*exprRuleset)(nil)
var (
envPool = sync.Pool{
New: func() any {
return make(map[string]any, 16)
},
}
subMapPool = sync.Pool{
New: func() any {
return make(map[string]any, 8)
},
}
)
type exprRuleset struct {
Rules []compiledExprRule
Ans []analyzer.Analyzer
Logger Logger
GeoMatcher *geo.GeoMatcher
stats *statsCounters
}
func (r *exprRuleset) Analyzers(info StreamInfo) []analyzer.Analyzer {
return r.Ans
}
func (r *exprRuleset) Match(info StreamInfo) MatchResult {
start := time.Now()
if r.stats != nil {
r.stats.MatchCalls.Add(1)
defer func() {
r.stats.MatchLatencyNanos.Add(uint64(time.Since(start).Nanoseconds()))
}()
}
env := envPool.Get().(map[string]any)
clear(env)
macMap, ipMap, portMap := populateExprEnv(env, info)
releaseEnv := func() {
clear(env)
envPool.Put(env)
putSubMap(macMap)
putSubMap(ipMap)
putSubMap(portMap)
}
now := time.Now()
for _, rule := range r.Rules {
if !matchTime(now, rule.StartTimeSecs, rule.StopTimeSecs, rule.Weekdays, rule.WeekdaysNegated) {
continue
}
v, err := vm.Run(rule.Program, env)
if err != nil {
if r.stats != nil {
r.stats.MatchErrors.Add(1)
}
r.Logger.MatchError(info, rule.Name, err)
continue
}
if vBool, ok := v.(bool); ok && vBool {
if rule.Log {
logInfo := info
if len(rule.GeoSiteConditions) > 0 && r.GeoMatcher != nil {
logInfo = addGeoSiteLogMetadata(logInfo, r.GeoMatcher, rule.GeoSiteConditions)
}
r.Logger.Log(logInfo, rule.Name)
}
if rule.Action != nil {
releaseEnv()
return MatchResult{
Action: *rule.Action,
ModInstance: rule.ModInstance,
}
}
}
}
releaseEnv()
return MatchResult{
Action: ActionMaybe,
}
}
func (r *exprRuleset) Stats() Stats {
if r == nil || r.stats == nil {
return Stats{}
}
return Stats{
MatchCalls: r.stats.MatchCalls.Load(),
MatchErrors: r.stats.MatchErrors.Load(),
MatchLatencyNanos: r.stats.MatchLatencyNanos.Load(),
LookupCalls: r.stats.LookupCalls.Load(),
LookupErrors: r.stats.LookupErrors.Load(),
LookupLatencyNanos: r.stats.LookupLatencyNanos.Load(),
}
}
// CompileExprRules compiles a list of expression rules into a ruleset.
// It returns an error if any of the rules are invalid, or if any of the analyzers
// used by the rules are unknown (not provided in the analyzer list).
func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier.Modifier, config *BuiltinConfig) (Ruleset, error) {
var compiledRules []compiledExprRule
fullAnMap := analyzersToMap(ans)
fullModMap := modifiersToMap(mods)
depAnMap := make(map[string]analyzer.Analyzer)
stats := &statsCounters{}
funcMap, geoMatcher := buildFunctionMap(config, stats)
// Compile all rules and build a map of analyzers that are used by the rules.
for _, rule := range rules {
if rule.Action == "" && !rule.Log {
return nil, fmt.Errorf("rule %q must have at least one of action or log", rule.Name)
}
var action *Action
if rule.Action != "" {
a, ok := actionStringToAction(rule.Action)
if !ok {
return nil, fmt.Errorf("rule %q has invalid action %q", rule.Name, rule.Action)
}
action = &a
}
visitor := &idVisitor{Variables: make(map[string]bool), Identifiers: make(map[string]bool)}
patcher := &idPatcher{FuncMap: funcMap, GeoMatcher: geoMatcher}
program, err := expr.Compile(rule.Expr,
func(c *conf.Config) {
c.Strict = false
c.Expect = reflect.Bool
c.Visitors = append(c.Visitors, visitor, patcher)
for name, f := range funcMap {
c.Functions[name] = &builtin.Function{
Name: name,
Func: f.Func,
Types: f.Types,
}
}
},
)
if err != nil {
return nil, fmt.Errorf("rule %q has invalid expression: %w", rule.Name, err)
}
if patcher.Err != nil {
return nil, fmt.Errorf("rule %q failed to patch expression: %w", rule.Name, patcher.Err)
}
for name := range visitor.Identifiers {
// Skip built-in analyzers & user-defined variables
if isBuiltInAnalyzer(name) || visitor.Variables[name] {
continue
}
if f, ok := funcMap[name]; ok {
// Built-in function, initialize if necessary
if f.InitFunc != nil {
if err := f.InitFunc(); err != nil {
return nil, fmt.Errorf("rule %q failed to initialize function %q: %w", rule.Name, name, err)
}
}
} else if a, ok := fullAnMap[name]; ok {
// Analyzer, add to dependency map
depAnMap[name] = a
}
}
startSecs := -1
if rule.StartTime != "" {
startSecs, err = parseTimeOfDay(rule.StartTime)
if err != nil {
return nil, fmt.Errorf("rule %q has invalid start_time: %w", rule.Name, err)
}
}
stopSecs := -1
if rule.StopTime != "" {
stopSecs, err = parseTimeOfDay(rule.StopTime)
if err != nil {
return nil, fmt.Errorf("rule %q has invalid stop_time: %w", rule.Name, err)
}
}
weekdays, weekdaysNegated, err := parseWeekdays(rule.Weekdays)
if err != nil {
return nil, fmt.Errorf("rule %q has invalid weekdays: %w", rule.Name, err)
}
cr := compiledExprRule{
Name: rule.Name,
Action: action,
Log: rule.Log,
Program: program,
GeoSiteConditions: extractGeoSiteConditions(rule.Expr),
StartTimeSecs: startSecs,
StopTimeSecs: stopSecs,
Weekdays: weekdays,
WeekdaysNegated: weekdaysNegated,
}
if action != nil && *action == ActionModify {
mod, ok := fullModMap[rule.Modifier.Name]
if !ok {
return nil, fmt.Errorf("rule %q uses unknown modifier %q", rule.Name, rule.Modifier.Name)
}
modInst, err := mod.New(rule.Modifier.Args)
if err != nil {
return nil, fmt.Errorf("rule %q failed to create modifier instance: %w", rule.Name, err)
}
cr.ModInstance = modInst
}
compiledRules = append(compiledRules, cr)
}
// Convert the analyzer map to a list.
var depAns []analyzer.Analyzer
for _, a := range depAnMap {
depAns = append(depAns, a)
}
return &exprRuleset{
Rules: compiledRules,
Ans: depAns,
Logger: config.Logger,
GeoMatcher: geoMatcher,
stats: stats,
}, nil
}
func populateExprEnv(m map[string]any, info StreamInfo) (macMap, ipMap, portMap map[string]any) {
macMap = getSubMap()
ipMap = getSubMap()
portMap = getSubMap()
macMap["src"] = info.SrcMAC.String()
macMap["dst"] = info.DstMAC.String()
ipMap["src"] = info.SrcIP.String()
ipMap["dst"] = info.DstIP.String()
portMap["src"] = info.SrcPort
portMap["dst"] = info.DstPort
m["id"] = info.ID
m["proto"] = info.Protocol.String()
m["mac"] = macMap
m["ip"] = ipMap
m["port"] = portMap
for anName, anProps := range info.Props {
if len(anProps) != 0 {
m[anName] = anProps
}
}
return macMap, ipMap, portMap
}
func getSubMap() map[string]any {
m := subMapPool.Get().(map[string]any)
clear(m)
return m
}
func putSubMap(m map[string]any) {
if m == nil {
return
}
clear(m)
subMapPool.Put(m)
}
func isBuiltInAnalyzer(name string) bool {
switch name {
case "id", "proto", "mac", "ip", "port":
return true
default:
return false
}
}
func actionStringToAction(action string) (Action, bool) {
switch strings.ToLower(action) {
case "allow":
return ActionAllow, true
case "block":
return ActionBlock, true
case "drop":
return ActionDrop, true
case "modify":
return ActionModify, true
default:
return ActionMaybe, false
}
}
// analyzersToMap converts a list of analyzers to a map of name -> analyzer.
// This is for easier lookup when compiling rules.
func analyzersToMap(ans []analyzer.Analyzer) map[string]analyzer.Analyzer {
anMap := make(map[string]analyzer.Analyzer)
for _, a := range ans {
anMap[a.Name()] = a
}
return anMap
}
// modifiersToMap converts a list of modifiers to a map of name -> modifier.
// This is for easier lookup when compiling rules.
func modifiersToMap(mods []modifier.Modifier) map[string]modifier.Modifier {
modMap := make(map[string]modifier.Modifier)
for _, m := range mods {
modMap[m.Name()] = m
}
return modMap
}
// idVisitor is a visitor that collects all identifiers in an expression.
// This is for determining which analyzers are used by the expression.
type idVisitor struct {
Variables map[string]bool
Identifiers map[string]bool
}
func (v *idVisitor) Visit(node *ast.Node) {
if varNode, ok := (*node).(*ast.VariableDeclaratorNode); ok {
v.Variables[varNode.Name] = true
} else if idNode, ok := (*node).(*ast.IdentifierNode); ok {
v.Identifiers[idNode.Value] = true
}
}
// idPatcher patches the AST during expr compilation, replacing certain values with
// their internal representations for better runtime performance.
type idPatcher struct {
FuncMap map[string]*Function
GeoMatcher *geo.GeoMatcher
Err error
}
func (p *idPatcher) Visit(node *ast.Node) {
if p.tryPatchGeoSiteORChain(node) {
return
}
switch (*node).(type) {
case *ast.CallNode:
callNode := (*node).(*ast.CallNode)
if callNode.Callee == nil {
// Ignore invalid call nodes
return
}
if f, ok := p.FuncMap[callNode.Callee.String()]; ok {
if f.PatchFunc != nil {
if err := f.PatchFunc(&callNode.Arguments); err != nil {
p.Err = err
return
}
}
}
}
}
func (p *idPatcher) tryPatchGeoSiteORChain(node *ast.Node) bool {
if p == nil || p.GeoMatcher == nil {
return false
}
terms, ok := collectGeoSiteORChain(*node)
if !ok || len(terms) < 2 {
return false
}
hostExpr := strings.TrimSpace(terms[0].hostExpr)
if hostExpr == "" {
return false
}
conditions := make([]string, 0, len(terms))
for _, term := range terms {
if strings.TrimSpace(term.hostExpr) != hostExpr {
return false
}
conditions = append(conditions, term.condition)
}
normalized := normalizeUniqueLowerStrings(conditions)
if len(normalized) < 2 {
return false
}
hostNode, err := parser.Parse(hostExpr)
if err != nil || hostNode == nil || hostNode.Node == nil {
return false
}
call := &ast.CallNode{
Callee: &ast.IdentifierNode{Value: "geosite_set"},
Arguments: []ast.Node{
hostNode.Node,
&ast.ConstantNode{Value: &geo.SiteConditionSet{Conditions: normalized}},
},
}
ast.Patch(node, call)
return true
}
type geositeTerm struct {
hostExpr string
condition string
}
func collectGeoSiteORChain(node ast.Node) ([]geositeTerm, bool) {
switch n := node.(type) {
case *ast.BinaryNode:
if n.Operator != "or" && n.Operator != "||" {
return nil, false
}
left, ok := collectGeoSiteORChain(n.Left)
if !ok {
return nil, false
}
right, ok := collectGeoSiteORChain(n.Right)
if !ok {
return nil, false
}
out := make([]geositeTerm, 0, len(left)+len(right))
out = append(out, left...)
out = append(out, right...)
return out, true
case *ast.CallNode:
idNode, ok := n.Callee.(*ast.IdentifierNode)
if !ok || len(n.Arguments) < 2 {
return nil, false
}
name := strings.ToLower(idNode.Value)
if name == "geosite" {
condNode, ok := n.Arguments[1].(*ast.StringNode)
if !ok {
return nil, false
}
return []geositeTerm{{
hostExpr: n.Arguments[0].String(),
condition: condNode.Value,
}}, true
}
if name != "geosite_set" {
return nil, false
}
setNode, ok := n.Arguments[1].(*ast.ConstantNode)
if !ok || setNode.Value == nil {
return nil, false
}
set, ok := setNode.Value.(*geo.SiteConditionSet)
if !ok || set == nil {
return nil, false
}
if len(set.Conditions) == 0 {
return nil, false
}
out := make([]geositeTerm, 0, len(set.Conditions))
hostExpr := n.Arguments[0].String()
for _, condition := range set.Conditions {
out = append(out, geositeTerm{hostExpr: hostExpr, condition: condition})
}
return out, true
default:
return nil, false
}
}
type Function struct {
InitFunc func() error
PatchFunc func(args *[]ast.Node) error
Func func(params ...any) (any, error)
Types []reflect.Type
}
func buildFunctionMap(config *BuiltinConfig, stats *statsCounters) (map[string]*Function, *geo.GeoMatcher) {
geoMatcher := geo.NewGeoMatcher(config.GeoSiteFilename, config.GeoIpFilename)
return map[string]*Function{
"geoip": {
InitFunc: geoMatcher.LoadGeoIP,
PatchFunc: nil,
Func: func(params ...any) (any, error) {
return geoMatcher.MatchGeoIp(params[0].(string), params[1].(string)), nil
},
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoIp)},
},
"geosite": {
InitFunc: geoMatcher.LoadGeoSite,
PatchFunc: nil,
Func: func(params ...any) (any, error) {
return geoMatcher.MatchGeoSite(params[0].(string), params[1].(string)), nil
},
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoSite)},
},
"geosite_set": {
InitFunc: geoMatcher.LoadGeoSite,
PatchFunc: nil,
Func: func(params ...any) (any, error) {
return geoMatcher.MatchGeoSiteSet(params[0].(string), params[1].(*geo.SiteConditionSet)), nil
},
Types: []reflect.Type{
reflect.TypeOf((func(string, *geo.SiteConditionSet) bool)(nil)),
},
},
"cidr": {
InitFunc: nil,
PatchFunc: func(args *[]ast.Node) error {
cidrStringNode, ok := (*args)[1].(*ast.StringNode)
if !ok {
return fmt.Errorf("cidr: invalid argument type")
}
cidr, err := builtins.CompileCIDR(cidrStringNode.Value)
if err != nil {
return err
}
(*args)[1] = &ast.ConstantNode{Value: cidr}
return nil
},
Func: func(params ...any) (any, error) {
return builtins.MatchCIDR(params[0].(string), params[1].(*net.IPNet)), nil
},
Types: []reflect.Type{reflect.TypeOf(builtins.MatchCIDR)},
},
"lookup": {
InitFunc: nil,
PatchFunc: func(args *[]ast.Node) error {
var serverStr *ast.StringNode
if len(*args) > 1 {
// Has the optional server argument
var ok bool
serverStr, ok = (*args)[1].(*ast.StringNode)
if !ok {
return fmt.Errorf("lookup: invalid argument type")
}
}
r := &net.Resolver{
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
if serverStr != nil {
address = serverStr.Value
}
return config.ProtectedDialContext(ctx, network, address)
},
}
if len(*args) > 1 {
(*args)[1] = &ast.ConstantNode{Value: r}
} else {
*args = append(*args, &ast.ConstantNode{Value: r})
}
return nil
},
Func: func(params ...any) (any, error) {
start := time.Now()
if stats != nil {
stats.LookupCalls.Add(1)
defer func() {
stats.LookupLatencyNanos.Add(uint64(time.Since(start).Nanoseconds()))
}()
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
out, err := params[1].(*net.Resolver).LookupHost(ctx, params[0].(string))
if err != nil && stats != nil {
stats.LookupErrors.Add(1)
}
return out, err
},
Types: []reflect.Type{
reflect.TypeOf((func(string, *net.Resolver) []string)(nil)),
},
},
}, geoMatcher
}
func matchTime(now time.Time, startSecs, stopSecs int, weekdays []time.Weekday, negated bool) bool {
if startSecs >= 0 || stopSecs >= 0 {
currentSecs := now.Hour()*3600 + now.Minute()*60 + now.Second()
if startSecs >= 0 && stopSecs >= 0 {
if startSecs <= stopSecs {
if currentSecs < startSecs || currentSecs > stopSecs {
return false
}
} else {
if currentSecs < startSecs && currentSecs > stopSecs {
return false
}
}
} else if startSecs >= 0 {
if currentSecs < startSecs {
return false
}
} else if currentSecs > stopSecs {
return false
}
}
if len(weekdays) > 0 {
current := now.Weekday()
found := false
for _, d := range weekdays {
if current == d {
found = true
break
}
}
if negated == found {
return false
}
}
return true
}
func parseTimeOfDay(s string) (int, error) {
t, err := time.Parse("15:04:05", s)
if err != nil {
return -1, fmt.Errorf("invalid time %q (expected hh:mm:ss)", s)
}
return t.Hour()*3600 + t.Minute()*60 + t.Second(), nil
}
func parseWeekdays(days []string) ([]time.Weekday, bool, error) {
if len(days) == 0 {
return nil, false, nil
}
negated := false
parsed := make([]time.Weekday, 0, len(days))
for i, d := range days {
d = strings.TrimSpace(d)
if i == 0 && strings.HasPrefix(d, "!") {
negated = true
d = strings.TrimSpace(strings.TrimPrefix(d, "!"))
}
var wd time.Weekday
switch strings.ToLower(d) {
case "sun", "sunday":
wd = time.Sunday
case "mon", "monday":
wd = time.Monday
case "tue", "tues", "tuesday":
wd = time.Tuesday
case "wed", "wednesday":
wd = time.Wednesday
case "thu", "thur", "thurs", "thursday":
wd = time.Thursday
case "fri", "friday":
wd = time.Friday
case "sat", "saturday":
wd = time.Saturday
default:
return nil, false, fmt.Errorf("invalid weekday %q", d)
}
parsed = append(parsed, wd)
}
return parsed, negated, nil
}
const rulesetLogMetaKey = "_ruleset"
func addGeoSiteLogMetadata(info StreamInfo, gm *geo.GeoMatcher, conditions []string) StreamInfo {
hosts := extractGeoSiteHostCandidates(info)
if len(hosts) == 0 {
return info
}
matchedGeoSites := matchGeoSiteConditions(hosts, conditions, gm.MatchGeoSite)
if len(matchedGeoSites) == 0 {
return info
}
clonedProps := cloneCombinedPropMap(info.Props)
clonedProps[rulesetLogMetaKey] = analyzer.PropMap{
"geosite": matchedGeoSites,
"hosts": hosts,
}
info.Props = clonedProps
return info
}
func extractGeoSiteHostCandidates(info StreamInfo) []string {
out := make([]string, 0, 4)
seen := make(map[string]struct{}, 4)
add := func(raw string) {
host := normalizeHost(raw)
if host == "" {
return
}
if _, ok := seen[host]; ok {
return
}
seen[host] = struct{}{}
out = append(out, host)
}
if sni, ok := info.Props.Get("tls", "req.sni").(string); ok {
add(sni)
}
if sni, ok := info.Props.Get("quic", "req.sni").(string); ok {
add(sni)
}
if host, ok := info.Props.Get("http", "req.headers.host").(string); ok {
add(host)
}
if addr, ok := info.Props.Get("socks", "req.addr").(string); ok {
add(addr)
}
qs := info.Props.Get("dns", "questions")
switch v := qs.(type) {
case []analyzer.PropMap:
for _, q := range v {
if name, ok := q["name"].(string); ok {
add(name)
}
}
case []interface{}:
for _, item := range v {
switch q := item.(type) {
case analyzer.PropMap:
if name, ok := q["name"].(string); ok {
add(name)
}
case map[string]interface{}:
if name, ok := q["name"].(string); ok {
add(name)
}
}
}
}
return out
}
func normalizeHost(raw string) string {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
return ""
}
// Handle bracketed host:port first, then unbracketed host:port.
if strings.HasPrefix(s, "[") {
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
} else if strings.Count(s, ":") == 1 {
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
}
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
s = strings.TrimSuffix(s, ".")
if s == "" || net.ParseIP(s) != nil {
return ""
}
return s
}
func matchGeoSiteConditions(hosts, conditions []string, matchFn func(site, condition string) bool) []string {
out := make([]string, 0, len(conditions))
seen := make(map[string]struct{}, len(conditions))
for _, cond := range conditions {
c := strings.TrimSpace(strings.ToLower(cond))
if c == "" {
continue
}
if _, ok := seen[c]; ok {
continue
}
for _, host := range hosts {
if matchFn(host, c) {
seen[c] = struct{}{}
out = append(out, c)
break
}
}
}
return out
}
func cloneCombinedPropMap(in analyzer.CombinedPropMap) analyzer.CombinedPropMap {
if in == nil {
return analyzer.CombinedPropMap{}
}
out := make(analyzer.CombinedPropMap, len(in)+1)
for k, v := range in {
out[k] = v
}
return out
}
func extractGeoSiteConditions(expression string) []string {
tree, err := parser.Parse(expression)
if err != nil || tree == nil || tree.Node == nil {
return nil
}
root := tree.Node
v := &geositeCallVisitor{
conditions: make([]string, 0, 4),
}
ast.Walk(&root, v)
return normalizeUniqueLowerStrings(v.conditions)
}
type geositeCallVisitor struct {
conditions []string
}
func (v *geositeCallVisitor) Visit(node *ast.Node) {
callNode, ok := (*node).(*ast.CallNode)
if !ok || callNode.Callee == nil || len(callNode.Arguments) < 2 {
return
}
idNode, ok := callNode.Callee.(*ast.IdentifierNode)
if !ok || strings.ToLower(idNode.Value) != "geosite" {
return
}
stringNode, ok := callNode.Arguments[1].(*ast.StringNode)
if !ok {
return
}
v.conditions = append(v.conditions, stringNode.Value)
}
func normalizeUniqueLowerStrings(in []string) []string {
out := make([]string, 0, len(in))
seen := make(map[string]struct{}, len(in))
for _, v := range in {
s := strings.TrimSpace(strings.ToLower(v))
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}