Skip to main content

rsigma_convert/backends/
test.rs

1//! Backend-neutral test backend modeled after pySigma's `TextQueryTestBackend`.
2//!
3//! Exercises most generic text backend features without targeting a specific SIEM.
4//! Used to validate the `Backend` trait, `TextQueryConfig`, condition walker,
5//! value escaping, modifier handling, and output formats.
6
7use std::collections::HashMap;
8
9use rsigma_eval::pipeline::state::PipelineState;
10use rsigma_parser::*;
11
12use crate::backend::*;
13use crate::condition::convert_condition_expr;
14use crate::convert::{default_convert_detection, default_convert_detection_item};
15use crate::error::{ConvertError, Result};
16use crate::state::{ConversionState, ConvertResult};
17
18// =============================================================================
19// TextQueryTestBackend config
20// =============================================================================
21
22pub static TEXT_QUERY_TEST_CONFIG: TextQueryConfig = TextQueryConfig {
23    precedence: (TokenType::NOT, TokenType::AND, TokenType::OR),
24    group_expression: "({expr})",
25    token_separator: " ",
26
27    and_token: "and",
28    or_token: "or",
29    not_token: "not",
30    eq_token: "=",
31
32    not_eq_token: Some("!="),
33    eq_expression: None,
34    not_eq_expression: None,
35    convert_not_as_not_eq: false,
36
37    wildcard_multi: "*",
38    wildcard_single: "?",
39
40    str_quote: "\"",
41    str_quote_pattern: None,
42    str_quote_pattern_negation: false,
43    escape_char: "\\",
44    add_escaped: &[":"],
45    filter_chars: &["&"],
46
47    field_quote: Some("'"),
48    field_quote_pattern: Some(r"^\w+$"),
49    field_quote_pattern_negation: true,
50    field_escape: None,
51    field_escape_pattern: None,
52
53    startswith_expression: Some("{field} startswith {value}"),
54    not_startswith_expression: None,
55    startswith_expression_allow_special: false,
56    endswith_expression: Some("{field} endswith {value}"),
57    not_endswith_expression: None,
58    endswith_expression_allow_special: false,
59    contains_expression: Some("{field} contains {value}"),
60    not_contains_expression: None,
61    contains_expression_allow_special: false,
62    wildcard_match_expression: Some("{field} match {value}"),
63
64    case_sensitive_match_expression: Some("{field} casematch {value}"),
65    case_sensitive_startswith_expression: Some("{field} startswith_cased {value}"),
66    case_sensitive_endswith_expression: Some("{field} endswith_cased {value}"),
67    case_sensitive_contains_expression: Some("{field} contains_cased {value}"),
68
69    re_expression: Some("{field}=/{regex}/"),
70    not_re_expression: None,
71    re_escape_char: Some("\\"),
72    re_escape: &["/"],
73    re_escape_escape_char: None,
74
75    cidr_expression: Some("cidrmatch(\"{value}\", {field})"),
76    not_cidr_expression: None,
77
78    field_null_expression: "{field} is null",
79    field_exists_expression: Some("exists({field})"),
80    field_not_exists_expression: Some("notexists({field})"),
81
82    compare_op_expression: Some("{field}{op}{value}"),
83    compare_ops: &[
84        ("lt", "<"),
85        ("lte", "<="),
86        ("gt", ">"),
87        ("gte", ">="),
88        ("neq", "!="),
89    ],
90
91    convert_or_as_in: true,
92    convert_and_as_in: true,
93    in_expressions_allow_wildcards: true,
94    field_in_list_expression: Some("{field} {op} ({list})"),
95    or_in_operator: Some("in"),
96    and_in_operator: Some("contains-all"),
97    list_separator: ", ",
98
99    unbound_value_str_expression: Some("_={value}"),
100    unbound_value_num_expression: Some("_={value}"),
101    unbound_value_re_expression: Some("_=/{value}/"),
102
103    field_eq_field_expression: Some("{field1}=fieldref({field2})"),
104    field_eq_field_escaping_quoting: true,
105
106    deferred_start: Some(" | "),
107    deferred_separator: Some(" | "),
108    deferred_only_query: "*",
109
110    bool_true: "1",
111    bool_false: "0",
112    query_expression: "{query}",
113    state_defaults: &[],
114};
115
116// =============================================================================
117// TextQueryTestBackend
118// =============================================================================
119
120pub struct TextQueryTestBackend {
121    pub config: &'static TextQueryConfig,
122}
123
124impl TextQueryTestBackend {
125    pub fn new() -> Self {
126        Self {
127            config: &TEXT_QUERY_TEST_CONFIG,
128        }
129    }
130}
131
132impl Default for TextQueryTestBackend {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl Backend for TextQueryTestBackend {
139    fn name(&self) -> &str {
140        "test"
141    }
142
143    fn formats(&self) -> &[(&str, &str)] {
144        &[
145            ("default", "plain query list"),
146            ("test", "wrapped query [ {query} ]"),
147            ("state", "index={state.index} ({query})"),
148            ("str", "newline-joined queries"),
149        ]
150    }
151
152    fn requires_pipeline(&self) -> bool {
153        false
154    }
155
156    // --- Detection rule conversion ---
157
158    fn convert_rule(
159        &self,
160        rule: &SigmaRule,
161        output_format: &str,
162        pipeline_state: &PipelineState,
163    ) -> Result<Vec<String>> {
164        let mut queries = Vec::new();
165
166        for (idx, cond_expr) in rule.detection.conditions.iter().enumerate() {
167            let mut state = ConversionState::new(pipeline_state.state.clone());
168            let query = self.convert_condition(cond_expr, &rule.detection.named, &mut state)?;
169            let finished = self.finish_query(rule, query, &state)?;
170            let finalized = self.finalize_query(rule, finished, idx, &state, output_format)?;
171            queries.push(finalized);
172        }
173
174        Ok(queries)
175    }
176
177    // --- Condition tree dispatch ---
178
179    fn convert_condition(
180        &self,
181        expr: &ConditionExpr,
182        detections: &HashMap<String, Detection>,
183        state: &mut ConversionState,
184    ) -> Result<String> {
185        convert_condition_expr(self, expr, detections, state)
186    }
187
188    fn convert_condition_and(&self, exprs: &[String]) -> Result<String> {
189        Ok(text_convert_condition_and(self.config, exprs))
190    }
191
192    fn convert_condition_or(&self, exprs: &[String]) -> Result<String> {
193        Ok(text_convert_condition_or(self.config, exprs))
194    }
195
196    fn convert_condition_not(&self, expr: &str) -> Result<String> {
197        Ok(text_convert_condition_not(self.config, expr))
198    }
199
200    // --- Detection ---
201
202    fn convert_detection(&self, det: &Detection, state: &mut ConversionState) -> Result<String> {
203        default_convert_detection(self, det, state)
204    }
205
206    fn convert_detection_item(
207        &self,
208        item: &DetectionItem,
209        state: &mut ConversionState,
210    ) -> Result<String> {
211        default_convert_detection_item(self, item, state)
212    }
213
214    // --- Field/value escaping ---
215
216    fn escape_and_quote_field(&self, field: &str) -> String {
217        text_escape_and_quote_field(self.config, field)
218    }
219
220    fn convert_value_str(&self, value: &SigmaString, _state: &ConversionState) -> String {
221        text_convert_value_str(self.config, value)
222    }
223
224    fn convert_value_re(&self, regex: &str, _state: &ConversionState) -> String {
225        text_convert_value_re(self.config, regex)
226    }
227
228    // --- Value-type-specific methods ---
229
230    fn convert_field_eq_str(
231        &self,
232        field: &str,
233        value: &SigmaString,
234        modifiers: &[Modifier],
235        state: &mut ConversionState,
236    ) -> Result<ConvertResult> {
237        text_convert_field_eq_str(self.config, field, value, modifiers, state)
238    }
239
240    fn convert_field_eq_str_case_sensitive(
241        &self,
242        field: &str,
243        value: &SigmaString,
244        modifiers: &[Modifier],
245        state: &mut ConversionState,
246    ) -> Result<ConvertResult> {
247        let mut mods = modifiers.to_vec();
248        if !mods.contains(&Modifier::Cased) {
249            mods.push(Modifier::Cased);
250        }
251        text_convert_field_eq_str(self.config, field, value, &mods, state)
252    }
253
254    fn convert_field_eq_num(
255        &self,
256        field: &str,
257        value: f64,
258        _state: &mut ConversionState,
259    ) -> Result<String> {
260        let f = text_escape_and_quote_field(self.config, field);
261        if value.fract() == 0.0 {
262            Ok(format!("{f}={}", value as i64))
263        } else {
264            Ok(format!("{f}={value}"))
265        }
266    }
267
268    fn convert_field_eq_bool(
269        &self,
270        field: &str,
271        value: bool,
272        _state: &mut ConversionState,
273    ) -> Result<String> {
274        let f = text_escape_and_quote_field(self.config, field);
275        let v = if value {
276            self.config.bool_true
277        } else {
278            self.config.bool_false
279        };
280        Ok(format!("{f}={v}"))
281    }
282
283    fn convert_field_eq_null(&self, field: &str, _state: &mut ConversionState) -> Result<String> {
284        let f = text_escape_and_quote_field(self.config, field);
285        Ok(self.config.field_null_expression.replace("{field}", &f))
286    }
287
288    fn convert_field_eq_re(
289        &self,
290        field: &str,
291        pattern: &str,
292        _flags: &[Modifier],
293        _state: &mut ConversionState,
294    ) -> Result<ConvertResult> {
295        let f = text_escape_and_quote_field(self.config, field);
296        let re_val = text_convert_value_re(self.config, pattern);
297        let expr = self
298            .config
299            .re_expression
300            .ok_or_else(|| ConvertError::UnsupportedModifier("regex".into()))?;
301        Ok(ConvertResult::Query(
302            expr.replace("{field}", &f).replace("{regex}", &re_val),
303        ))
304    }
305
306    fn convert_field_eq_cidr(
307        &self,
308        field: &str,
309        cidr: &str,
310        _state: &mut ConversionState,
311    ) -> Result<ConvertResult> {
312        let f = text_escape_and_quote_field(self.config, field);
313        let expr = self
314            .config
315            .cidr_expression
316            .ok_or_else(|| ConvertError::UnsupportedModifier("cidr".into()))?;
317        Ok(ConvertResult::Query(
318            expr.replace("{field}", &f).replace("{value}", cidr),
319        ))
320    }
321
322    fn convert_field_compare(
323        &self,
324        field: &str,
325        op: &Modifier,
326        value: f64,
327        _state: &mut ConversionState,
328    ) -> Result<String> {
329        let f = text_escape_and_quote_field(self.config, field);
330        let op_name = match op {
331            Modifier::Lt => "lt",
332            Modifier::Lte => "lte",
333            Modifier::Gt => "gt",
334            Modifier::Gte => "gte",
335            _ => {
336                return Err(ConvertError::UnsupportedModifier(format!(
337                    "compare op {:?}",
338                    op
339                )));
340            }
341        };
342        let op_token = self
343            .config
344            .compare_ops
345            .iter()
346            .find(|(name, _)| *name == op_name)
347            .map(|(_, token)| *token)
348            .ok_or_else(|| ConvertError::UnsupportedModifier(op_name.into()))?;
349
350        let expr = self
351            .config
352            .compare_op_expression
353            .ok_or_else(|| ConvertError::UnsupportedModifier("compare".into()))?;
354
355        let val_str = if value.fract() == 0.0 {
356            (value as i64).to_string()
357        } else {
358            value.to_string()
359        };
360        Ok(expr
361            .replace("{field}", &f)
362            .replace("{op}", op_token)
363            .replace("{value}", &val_str))
364    }
365
366    fn convert_field_exists(
367        &self,
368        field: &str,
369        exists: bool,
370        _state: &mut ConversionState,
371    ) -> Result<String> {
372        let f = text_escape_and_quote_field(self.config, field);
373        if exists {
374            let expr = self
375                .config
376                .field_exists_expression
377                .ok_or_else(|| ConvertError::UnsupportedModifier("exists".into()))?;
378            Ok(expr.replace("{field}", &f))
379        } else {
380            let expr = self
381                .config
382                .field_not_exists_expression
383                .ok_or_else(|| ConvertError::UnsupportedModifier("not exists".into()))?;
384            Ok(expr.replace("{field}", &f))
385        }
386    }
387
388    fn convert_field_eq_query_expr(
389        &self,
390        field: &str,
391        expr: &str,
392        _id: &str,
393        _state: &mut ConversionState,
394    ) -> Result<String> {
395        let f = text_escape_and_quote_field(self.config, field);
396        Ok(format!("{f}={expr}"))
397    }
398
399    fn convert_field_ref(
400        &self,
401        field1: &str,
402        field2: &str,
403        _state: &mut ConversionState,
404    ) -> Result<ConvertResult> {
405        let expr = self
406            .config
407            .field_eq_field_expression
408            .ok_or_else(|| ConvertError::UnsupportedModifier("fieldref".into()))?;
409        let f1 = text_escape_and_quote_field(self.config, field1);
410        let f2 = if self.config.field_eq_field_escaping_quoting {
411            text_escape_and_quote_field(self.config, field2)
412        } else {
413            field2.to_string()
414        };
415        Ok(ConvertResult::Query(
416            expr.replace("{field1}", &f1).replace("{field2}", &f2),
417        ))
418    }
419
420    fn convert_keyword(&self, value: &SigmaValue, _state: &mut ConversionState) -> Result<String> {
421        match value {
422            SigmaValue::String(s) => {
423                let v = text_convert_value_str(self.config, s);
424                let expr = self
425                    .config
426                    .unbound_value_str_expression
427                    .ok_or(ConvertError::UnsupportedKeyword)?;
428                Ok(expr.replace("{value}", &v))
429            }
430            SigmaValue::Integer(n) => {
431                let expr = self
432                    .config
433                    .unbound_value_num_expression
434                    .ok_or(ConvertError::UnsupportedKeyword)?;
435                Ok(expr.replace("{value}", &n.to_string()))
436            }
437            SigmaValue::Float(f) => {
438                let expr = self
439                    .config
440                    .unbound_value_num_expression
441                    .ok_or(ConvertError::UnsupportedKeyword)?;
442                Ok(expr.replace("{value}", &f.to_string()))
443            }
444            _ => Err(ConvertError::UnsupportedKeyword),
445        }
446    }
447
448    fn convert_condition_as_in_expression(
449        &self,
450        field: &str,
451        values: &[&SigmaValue],
452        is_or: bool,
453        _state: &mut ConversionState,
454    ) -> Result<String> {
455        let f = text_escape_and_quote_field(self.config, field);
456        let expr = self
457            .config
458            .field_in_list_expression
459            .ok_or_else(|| ConvertError::UnsupportedModifier("in-list".into()))?;
460        let op = if is_or {
461            self.config
462                .or_in_operator
463                .ok_or_else(|| ConvertError::UnsupportedModifier("or-in".into()))?
464        } else {
465            self.config
466                .and_in_operator
467                .ok_or_else(|| ConvertError::UnsupportedModifier("and-in".into()))?
468        };
469
470        let items: Vec<String> = values
471            .iter()
472            .map(|v| match v {
473                SigmaValue::String(s) => text_convert_value_str(self.config, s),
474                SigmaValue::Integer(n) => n.to_string(),
475                SigmaValue::Float(f) => f.to_string(),
476                _ => String::new(),
477            })
478            .collect();
479
480        let list = items.join(self.config.list_separator);
481        Ok(expr
482            .replace("{field}", &f)
483            .replace("{op}", op)
484            .replace("{list}", &list))
485    }
486
487    // --- Query finalization ---
488
489    fn finish_query(
490        &self,
491        rule: &SigmaRule,
492        query: String,
493        state: &ConversionState,
494    ) -> Result<String> {
495        Ok(text_finish_query(self.config, &query, state, rule))
496    }
497
498    fn finalize_query(
499        &self,
500        _rule: &SigmaRule,
501        query: String,
502        _index: usize,
503        state: &ConversionState,
504        output_format: &str,
505    ) -> Result<String> {
506        match output_format {
507            "default" => Ok(query),
508            "test" => Ok(format!("[ {query} ]")),
509            "state" => {
510                let index = state.get_state_str("index").unwrap_or("default_index");
511                Ok(format!("index={index} ({query})"))
512            }
513            "str" => Ok(query),
514            other => Err(ConvertError::RuleConversion(format!(
515                "unknown output format: {other}"
516            ))),
517        }
518    }
519
520    fn finalize_output(&self, queries: Vec<String>, output_format: &str) -> Result<String> {
521        match output_format {
522            "str" => Ok(queries.join("\n")),
523            _ => Ok(queries.join("\n")),
524        }
525    }
526}
527
528// =============================================================================
529// MandatoryPipelineTestBackend
530// =============================================================================
531
532/// Variant that requires a pipeline (for testing the pipeline-required error path).
533pub struct MandatoryPipelineTestBackend(TextQueryTestBackend);
534
535impl MandatoryPipelineTestBackend {
536    pub fn new() -> Self {
537        Self(TextQueryTestBackend::new())
538    }
539}
540
541impl Default for MandatoryPipelineTestBackend {
542    fn default() -> Self {
543        Self::new()
544    }
545}
546
547impl Backend for MandatoryPipelineTestBackend {
548    fn name(&self) -> &str {
549        "test_mandatory_pipeline"
550    }
551
552    fn formats(&self) -> &[(&str, &str)] {
553        self.0.formats()
554    }
555
556    fn requires_pipeline(&self) -> bool {
557        true
558    }
559
560    fn convert_rule(
561        &self,
562        rule: &SigmaRule,
563        output_format: &str,
564        pipeline_state: &PipelineState,
565    ) -> Result<Vec<String>> {
566        self.0.convert_rule(rule, output_format, pipeline_state)
567    }
568
569    fn convert_condition(
570        &self,
571        expr: &ConditionExpr,
572        detections: &HashMap<String, Detection>,
573        state: &mut ConversionState,
574    ) -> Result<String> {
575        self.0.convert_condition(expr, detections, state)
576    }
577
578    fn convert_condition_and(&self, exprs: &[String]) -> Result<String> {
579        self.0.convert_condition_and(exprs)
580    }
581
582    fn convert_condition_or(&self, exprs: &[String]) -> Result<String> {
583        self.0.convert_condition_or(exprs)
584    }
585
586    fn convert_condition_not(&self, expr: &str) -> Result<String> {
587        self.0.convert_condition_not(expr)
588    }
589
590    fn convert_detection(&self, det: &Detection, state: &mut ConversionState) -> Result<String> {
591        self.0.convert_detection(det, state)
592    }
593
594    fn convert_detection_item(
595        &self,
596        item: &DetectionItem,
597        state: &mut ConversionState,
598    ) -> Result<String> {
599        self.0.convert_detection_item(item, state)
600    }
601
602    fn escape_and_quote_field(&self, field: &str) -> String {
603        self.0.escape_and_quote_field(field)
604    }
605
606    fn convert_value_str(&self, value: &SigmaString, state: &ConversionState) -> String {
607        self.0.convert_value_str(value, state)
608    }
609
610    fn convert_value_re(&self, regex: &str, state: &ConversionState) -> String {
611        self.0.convert_value_re(regex, state)
612    }
613
614    fn convert_field_eq_str(
615        &self,
616        field: &str,
617        value: &SigmaString,
618        modifiers: &[Modifier],
619        state: &mut ConversionState,
620    ) -> Result<ConvertResult> {
621        self.0.convert_field_eq_str(field, value, modifiers, state)
622    }
623
624    fn convert_field_eq_str_case_sensitive(
625        &self,
626        field: &str,
627        value: &SigmaString,
628        modifiers: &[Modifier],
629        state: &mut ConversionState,
630    ) -> Result<ConvertResult> {
631        self.0
632            .convert_field_eq_str_case_sensitive(field, value, modifiers, state)
633    }
634
635    fn convert_field_eq_num(
636        &self,
637        field: &str,
638        value: f64,
639        state: &mut ConversionState,
640    ) -> Result<String> {
641        self.0.convert_field_eq_num(field, value, state)
642    }
643
644    fn convert_field_eq_bool(
645        &self,
646        field: &str,
647        value: bool,
648        state: &mut ConversionState,
649    ) -> Result<String> {
650        self.0.convert_field_eq_bool(field, value, state)
651    }
652
653    fn convert_field_eq_null(&self, field: &str, state: &mut ConversionState) -> Result<String> {
654        self.0.convert_field_eq_null(field, state)
655    }
656
657    fn convert_field_eq_re(
658        &self,
659        field: &str,
660        pattern: &str,
661        flags: &[Modifier],
662        state: &mut ConversionState,
663    ) -> Result<ConvertResult> {
664        self.0.convert_field_eq_re(field, pattern, flags, state)
665    }
666
667    fn convert_field_eq_cidr(
668        &self,
669        field: &str,
670        cidr: &str,
671        state: &mut ConversionState,
672    ) -> Result<ConvertResult> {
673        self.0.convert_field_eq_cidr(field, cidr, state)
674    }
675
676    fn convert_field_compare(
677        &self,
678        field: &str,
679        op: &Modifier,
680        value: f64,
681        state: &mut ConversionState,
682    ) -> Result<String> {
683        self.0.convert_field_compare(field, op, value, state)
684    }
685
686    fn convert_field_exists(
687        &self,
688        field: &str,
689        exists: bool,
690        state: &mut ConversionState,
691    ) -> Result<String> {
692        self.0.convert_field_exists(field, exists, state)
693    }
694
695    fn convert_field_eq_query_expr(
696        &self,
697        field: &str,
698        expr: &str,
699        id: &str,
700        state: &mut ConversionState,
701    ) -> Result<String> {
702        self.0.convert_field_eq_query_expr(field, expr, id, state)
703    }
704
705    fn convert_field_ref(
706        &self,
707        field1: &str,
708        field2: &str,
709        state: &mut ConversionState,
710    ) -> Result<ConvertResult> {
711        self.0.convert_field_ref(field1, field2, state)
712    }
713
714    fn convert_keyword(&self, value: &SigmaValue, state: &mut ConversionState) -> Result<String> {
715        self.0.convert_keyword(value, state)
716    }
717
718    fn finish_query(
719        &self,
720        rule: &SigmaRule,
721        query: String,
722        state: &ConversionState,
723    ) -> Result<String> {
724        self.0.finish_query(rule, query, state)
725    }
726
727    fn finalize_query(
728        &self,
729        rule: &SigmaRule,
730        query: String,
731        index: usize,
732        state: &ConversionState,
733        output_format: &str,
734    ) -> Result<String> {
735        self.0
736            .finalize_query(rule, query, index, state, output_format)
737    }
738
739    fn finalize_output(&self, queries: Vec<String>, output_format: &str) -> Result<String> {
740        self.0.finalize_output(queries, output_format)
741    }
742}
743
744// =============================================================================
745// Tests
746// =============================================================================
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use rsigma_parser::parse_sigma_yaml;
752
753    fn convert_rule_yaml(yaml: &str) -> Vec<String> {
754        let collection = parse_sigma_yaml(yaml).unwrap();
755        let backend = TextQueryTestBackend::new();
756        let mut results = Vec::new();
757        for rule in &collection.rules {
758            let queries = backend
759                .convert_rule(rule, "default", &PipelineState::default())
760                .unwrap();
761            results.extend(queries);
762        }
763        results
764    }
765
766    #[test]
767    fn test_simple_eq() {
768        let queries = convert_rule_yaml(
769            r#"
770title: Test
771logsource:
772    category: test
773detection:
774    selection:
775        CommandLine: whoami
776    condition: selection
777"#,
778        );
779        assert_eq!(queries, vec!["CommandLine=\"whoami\""]);
780    }
781
782    #[test]
783    fn test_and_condition() {
784        let queries = convert_rule_yaml(
785            r#"
786title: Test
787logsource:
788    category: test
789detection:
790    sel1:
791        FieldA: val1
792    sel2:
793        FieldB: val2
794    condition: sel1 and sel2
795"#,
796        );
797        assert_eq!(queries, vec!["FieldA=\"val1\" and FieldB=\"val2\""]);
798    }
799
800    #[test]
801    fn test_or_condition() {
802        let queries = convert_rule_yaml(
803            r#"
804title: Test
805logsource:
806    category: test
807detection:
808    sel1:
809        FieldA: val1
810    sel2:
811        FieldB: val2
812    condition: sel1 or sel2
813"#,
814        );
815        assert_eq!(queries, vec!["FieldA=\"val1\" or FieldB=\"val2\""]);
816    }
817
818    #[test]
819    fn test_not_condition() {
820        let queries = convert_rule_yaml(
821            r#"
822title: Test
823logsource:
824    category: test
825detection:
826    selection:
827        FieldA: val1
828    filter:
829        FieldB: val2
830    condition: selection and not filter
831"#,
832        );
833        assert_eq!(queries, vec!["FieldA=\"val1\" and not FieldB=\"val2\""]);
834    }
835
836    #[test]
837    fn test_contains_modifier() {
838        let queries = convert_rule_yaml(
839            r#"
840title: Test
841logsource:
842    category: test
843detection:
844    selection:
845        CommandLine|contains: whoami
846    condition: selection
847"#,
848        );
849        assert_eq!(queries, vec!["CommandLine contains \"whoami\""]);
850    }
851
852    #[test]
853    fn test_startswith_modifier() {
854        let queries = convert_rule_yaml(
855            r#"
856title: Test
857logsource:
858    category: test
859detection:
860    selection:
861        CommandLine|startswith: cmd
862    condition: selection
863"#,
864        );
865        assert_eq!(queries, vec!["CommandLine startswith \"cmd\""]);
866    }
867
868    #[test]
869    fn test_endswith_modifier() {
870        let queries = convert_rule_yaml(
871            r#"
872title: Test
873logsource:
874    category: test
875detection:
876    selection:
877        CommandLine|endswith: '.exe'
878    condition: selection
879"#,
880        );
881        assert_eq!(queries, vec!["CommandLine endswith \".exe\""]);
882    }
883
884    #[test]
885    fn test_wildcard_value() {
886        let queries = convert_rule_yaml(
887            r#"
888title: Test
889logsource:
890    category: test
891detection:
892    selection:
893        CommandLine: '*whoami*'
894    condition: selection
895"#,
896        );
897        assert_eq!(queries, vec!["CommandLine match *whoami*"]);
898    }
899
900    #[test]
901    fn test_numeric_value() {
902        let queries = convert_rule_yaml(
903            r#"
904title: Test
905logsource:
906    category: test
907detection:
908    selection:
909        EventID: 4688
910    condition: selection
911"#,
912        );
913        assert_eq!(queries, vec!["EventID=4688"]);
914    }
915
916    #[test]
917    fn test_boolean_value() {
918        let queries = convert_rule_yaml(
919            r#"
920title: Test
921logsource:
922    category: test
923detection:
924    selection:
925        Enabled: true
926    condition: selection
927"#,
928        );
929        assert_eq!(queries, vec!["Enabled=1"]);
930    }
931
932    #[test]
933    fn test_null_value() {
934        let queries = convert_rule_yaml(
935            r#"
936title: Test
937logsource:
938    category: test
939detection:
940    selection:
941        FieldA: null
942    condition: selection
943"#,
944        );
945        assert_eq!(queries, vec!["FieldA is null"]);
946    }
947
948    #[test]
949    fn test_exists_modifier() {
950        let queries = convert_rule_yaml(
951            r#"
952title: Test
953logsource:
954    category: test
955detection:
956    selection:
957        FieldA|exists: true
958    condition: selection
959"#,
960        );
961        assert_eq!(queries, vec!["exists(FieldA)"]);
962    }
963
964    #[test]
965    fn test_not_exists_modifier() {
966        let queries = convert_rule_yaml(
967            r#"
968title: Test
969logsource:
970    category: test
971detection:
972    selection:
973        FieldA|exists: false
974    condition: selection
975"#,
976        );
977        assert_eq!(queries, vec!["notexists(FieldA)"]);
978    }
979
980    #[test]
981    fn test_re_modifier() {
982        let queries = convert_rule_yaml(
983            r#"
984title: Test
985logsource:
986    category: test
987detection:
988    selection:
989        CommandLine|re: '.*whoami.*'
990    condition: selection
991"#,
992        );
993        assert_eq!(queries, vec!["CommandLine=/.*whoami.*/"]);
994    }
995
996    #[test]
997    fn test_cidr_modifier() {
998        let queries = convert_rule_yaml(
999            r#"
1000title: Test
1001logsource:
1002    category: test
1003detection:
1004    selection:
1005        SourceIP|cidr: '10.0.0.0/8'
1006    condition: selection
1007"#,
1008        );
1009        assert_eq!(queries, vec!["cidrmatch(\"10.0.0.0/8\", SourceIP)"]);
1010    }
1011
1012    #[test]
1013    fn test_gte_modifier() {
1014        let queries = convert_rule_yaml(
1015            r#"
1016title: Test
1017logsource:
1018    category: test
1019detection:
1020    selection:
1021        EventCount|gte: 10
1022    condition: selection
1023"#,
1024        );
1025        assert_eq!(queries, vec!["EventCount>=10"]);
1026    }
1027
1028    #[test]
1029    fn test_multiple_values_or() {
1030        let queries = convert_rule_yaml(
1031            r#"
1032title: Test
1033logsource:
1034    category: test
1035detection:
1036    selection:
1037        CommandLine:
1038            - whoami
1039            - ipconfig
1040    condition: selection
1041"#,
1042        );
1043        assert_eq!(
1044            queries,
1045            vec!["CommandLine=\"whoami\" or CommandLine=\"ipconfig\""]
1046        );
1047    }
1048
1049    #[test]
1050    fn test_multiple_values_all() {
1051        let queries = convert_rule_yaml(
1052            r#"
1053title: Test
1054logsource:
1055    category: test
1056detection:
1057    selection:
1058        CommandLine|all:
1059            - whoami
1060            - ipconfig
1061    condition: selection
1062"#,
1063        );
1064        assert_eq!(
1065            queries,
1066            vec!["CommandLine=\"whoami\" and CommandLine=\"ipconfig\""]
1067        );
1068    }
1069
1070    #[test]
1071    fn test_escape_chars() {
1072        let queries = convert_rule_yaml(
1073            r#"
1074title: Test
1075logsource:
1076    category: test
1077detection:
1078    selection:
1079        FieldA: 'value:with&special'
1080    condition: selection
1081"#,
1082        );
1083        // `:` should be escaped with `\`, `&` should be filtered
1084        assert_eq!(queries, vec!["FieldA=\"value\\:withspecial\""]);
1085    }
1086
1087    #[test]
1088    fn test_output_format_test() {
1089        let collection = parse_sigma_yaml(
1090            r#"
1091title: Test
1092logsource:
1093    category: test
1094detection:
1095    selection:
1096        FieldA: val1
1097    condition: selection
1098"#,
1099        )
1100        .unwrap();
1101        let backend = TextQueryTestBackend::new();
1102        let queries = backend
1103            .convert_rule(&collection.rules[0], "test", &PipelineState::default())
1104            .unwrap();
1105        assert_eq!(queries, vec!["[ FieldA=\"val1\" ]"]);
1106    }
1107
1108    #[test]
1109    fn test_output_format_state() {
1110        let collection = parse_sigma_yaml(
1111            r#"
1112title: Test
1113logsource:
1114    category: test
1115detection:
1116    selection:
1117        FieldA: val1
1118    condition: selection
1119"#,
1120        )
1121        .unwrap();
1122        let backend = TextQueryTestBackend::new();
1123        let mut ps = PipelineState::default();
1124        ps.set_state(
1125            "index".to_string(),
1126            serde_json::Value::String("my_index".into()),
1127        );
1128        let queries = backend
1129            .convert_rule(&collection.rules[0], "state", &ps)
1130            .unwrap();
1131        assert_eq!(queries, vec!["index=my_index (FieldA=\"val1\")"]);
1132    }
1133
1134    #[test]
1135    fn test_mandatory_pipeline_error() {
1136        let collection = parse_sigma_yaml(
1137            r#"
1138title: Test
1139logsource:
1140    category: test
1141detection:
1142    selection:
1143        FieldA: val1
1144    condition: selection
1145"#,
1146        )
1147        .unwrap();
1148        let backend = MandatoryPipelineTestBackend::new();
1149        let result = crate::convert::convert_collection(&backend, &collection, &[], "default");
1150        assert!(matches!(result, Err(ConvertError::PipelineRequired)));
1151    }
1152
1153    #[test]
1154    fn test_multiple_detection_items_and() {
1155        let queries = convert_rule_yaml(
1156            r#"
1157title: Test
1158logsource:
1159    category: test
1160detection:
1161    selection:
1162        FieldA: val1
1163        FieldB: val2
1164    condition: selection
1165"#,
1166        );
1167        assert_eq!(queries, vec!["FieldA=\"val1\" and FieldB=\"val2\""]);
1168    }
1169
1170    #[test]
1171    fn test_keywords() {
1172        let queries = convert_rule_yaml(
1173            r#"
1174title: Test
1175logsource:
1176    category: test
1177detection:
1178    keywords:
1179        - whoami
1180        - ipconfig
1181    condition: keywords
1182"#,
1183        );
1184        assert_eq!(queries, vec!["_=\"whoami\" or _=\"ipconfig\""]);
1185    }
1186
1187    #[test]
1188    fn test_case_sensitive_contains() {
1189        let queries = convert_rule_yaml(
1190            r#"
1191title: Test
1192logsource:
1193    category: test
1194detection:
1195    selection:
1196        CommandLine|contains|cased: Whoami
1197    condition: selection
1198"#,
1199        );
1200        assert_eq!(queries, vec!["CommandLine contains_cased \"Whoami\""]);
1201    }
1202
1203    #[test]
1204    fn test_re_with_slash_escaping() {
1205        let queries = convert_rule_yaml(
1206            r#"
1207title: Test
1208logsource:
1209    category: test
1210detection:
1211    selection:
1212        Path|re: 'C:/Windows/.*'
1213    condition: selection
1214"#,
1215        );
1216        // `:` is in add_escaped for string values, not re_escape, so it stays unescaped.
1217        // `/` is in re_escape, so both slashes get escaped.
1218        assert_eq!(queries, vec!["Path=/C:\\/Windows\\/.*/"]);
1219    }
1220}