Skip to main content

rsigma_eval/pipeline/
transformations.rs

1//! Pipeline transformations that mutate `SigmaRule` AST nodes.
2//!
3//! All 26 pySigma transformation types are implemented as variants of the
4//! [`Transformation`] enum. Each variant carries its configuration parameters
5//! and is applied via the [`Transformation::apply`] method.
6
7use std::collections::HashMap;
8
9use regex::Regex;
10
11use rsigma_parser::{
12    ConditionExpr, Detection, DetectionItem, FieldSpec, SigmaRule, SigmaString, SigmaValue,
13    SpecialChar, StringPart,
14};
15
16use super::conditions::{DetectionItemCondition, FieldNameCondition};
17use super::state::PipelineState;
18use crate::error::{EvalError, Result};
19
20// =============================================================================
21// Transformation enum
22// =============================================================================
23
24/// All supported pipeline transformation types.
25#[derive(Debug, Clone)]
26pub enum Transformation {
27    /// Map field names via a lookup table.
28    FieldNameMapping { mapping: HashMap<String, String> },
29
30    /// Map field name prefixes.
31    FieldNamePrefixMapping { mapping: HashMap<String, String> },
32
33    /// Add a prefix to all matched field names.
34    FieldNamePrefix { prefix: String },
35
36    /// Add a suffix to all matched field names.
37    FieldNameSuffix { suffix: String },
38
39    /// Remove matching detection items.
40    DropDetectionItem,
41
42    /// Add field=value conditions to the rule's detection.
43    AddCondition {
44        conditions: HashMap<String, SigmaValue>,
45        /// If true, negate the added conditions.
46        negated: bool,
47    },
48
49    /// Replace logsource fields.
50    ChangeLogsource {
51        category: Option<String>,
52        product: Option<String>,
53        service: Option<String>,
54    },
55
56    /// Regex replacement in string values.
57    ///
58    /// When `skip_special` is true, replacement is applied only to the plain
59    /// (non-wildcard) segments of `SigmaString`, preserving `*` and `?` wildcards.
60    /// Mirrors pySigma's `ReplaceStringTransformation.skip_special`.
61    ReplaceString {
62        regex: String,
63        replacement: String,
64        skip_special: bool,
65    },
66
67    /// Expand `%name%` placeholders with pipeline variables.
68    ValuePlaceholders,
69
70    /// Replace unresolved `%name%` placeholders with `*` wildcard.
71    WildcardPlaceholders,
72
73    /// Store expression template (no-op for eval, kept for YAML compat).
74    QueryExpressionPlaceholders { expression: String },
75
76    /// Set key-value in pipeline state.
77    SetState { key: String, value: String },
78
79    /// Fail if rule conditions match.
80    RuleFailure { message: String },
81
82    /// Fail if detection item conditions match.
83    DetectionItemFailure { message: String },
84
85    /// Apply a named function to field names (lowercase, uppercase, etc.).
86    /// In pySigma this takes a Python callable; we support named functions.
87    FieldNameTransform {
88        /// One of: "lower", "upper", "title", "snake_case"
89        transform_func: String,
90        /// Explicit overrides: field → new_name (applied instead of the function).
91        mapping: HashMap<String, String>,
92    },
93
94    /// Decompose the `Hashes` field into per-algorithm fields.
95    ///
96    /// `Hashes: "SHA1=abc,MD5=def"` → `FileSHA1: abc` + `FileMD5: def`
97    HashesFields {
98        /// Allowed hash algorithms (e.g. `["MD5", "SHA1", "SHA256"]`).
99        valid_hash_algos: Vec<String>,
100        /// Prefix for generated field names (e.g. `"File"` → `FileMD5`).
101        field_prefix: String,
102        /// If true, omit algo name from field (use just prefix).
103        drop_algo_prefix: bool,
104    },
105
106    /// Map string values via a lookup table.
107    ///
108    /// Supports one-to-many mapping: a single value can map to multiple
109    /// alternatives (pySigma compat). When one-to-many is used, the detection
110    /// item's values list is expanded in place.
111    MapString {
112        mapping: HashMap<String, Vec<String>>,
113    },
114
115    /// Set all values of matching detection items to a fixed value.
116    SetValue { value: SigmaValue },
117
118    /// Convert detection item values to a different type.
119    /// Supported: "str", "int", "float", "bool".
120    ConvertType { target_type: String },
121
122    /// Convert plain string values to regex patterns.
123    Regex,
124
125    /// Add a field name to the rule's output `fields` list.
126    AddField { field: String },
127
128    /// Remove a field name from the rule's output `fields` list.
129    RemoveField { field: String },
130
131    /// Set (replace) the rule's output `fields` list.
132    SetField { fields: Vec<String> },
133
134    /// Set a custom attribute on the rule.
135    ///
136    /// Stores the key-value pair in `SigmaRule.custom_attributes`.
137    /// Backends / engines can read these to modify per-rule behavior
138    /// (e.g. `rsigma.suppress`, `rsigma.action`).
139    /// Mirrors pySigma's `SetCustomAttributeTransformation`.
140    SetCustomAttribute { attribute: String, value: String },
141
142    /// Apply a case transformation to string values.
143    /// Supported: "lower", "upper", "snake_case".
144    CaseTransformation { case_type: String },
145
146    /// Nested sub-pipeline: apply a list of transformations as a group.
147    /// The inner items share the same conditions as the outer item.
148    Nest {
149        items: Vec<super::TransformationItem>,
150    },
151}
152
153// =============================================================================
154// Application logic
155// =============================================================================
156
157impl Transformation {
158    /// Apply this transformation to a `SigmaRule`, mutating it in place.
159    ///
160    /// Returns `Ok(true)` if the transformation was applied, `Ok(false)` if skipped.
161    pub fn apply(
162        &self,
163        rule: &mut SigmaRule,
164        state: &mut PipelineState,
165        detection_item_conditions: &[DetectionItemCondition],
166        field_name_conditions: &[FieldNameCondition],
167        field_name_cond_not: bool,
168    ) -> Result<bool> {
169        match self {
170            Transformation::FieldNameMapping { mapping } => {
171                apply_field_name_transform(
172                    rule,
173                    state,
174                    field_name_conditions,
175                    field_name_cond_not,
176                    |name| mapping.get(name).cloned(),
177                );
178                Ok(true)
179            }
180
181            Transformation::FieldNamePrefixMapping { mapping } => {
182                apply_field_name_transform(
183                    rule,
184                    state,
185                    field_name_conditions,
186                    field_name_cond_not,
187                    |name| {
188                        for (prefix, replacement) in mapping {
189                            if name.starts_with(prefix.as_str()) {
190                                return Some(format!("{}{}", replacement, &name[prefix.len()..]));
191                            }
192                        }
193                        None
194                    },
195                );
196                Ok(true)
197            }
198
199            Transformation::FieldNamePrefix { prefix } => {
200                apply_field_name_transform(
201                    rule,
202                    state,
203                    field_name_conditions,
204                    field_name_cond_not,
205                    |name| Some(format!("{prefix}{name}")),
206                );
207                Ok(true)
208            }
209
210            Transformation::FieldNameSuffix { suffix } => {
211                apply_field_name_transform(
212                    rule,
213                    state,
214                    field_name_conditions,
215                    field_name_cond_not,
216                    |name| Some(format!("{name}{suffix}")),
217                );
218                Ok(true)
219            }
220
221            Transformation::DropDetectionItem => {
222                drop_detection_items(
223                    rule,
224                    state,
225                    detection_item_conditions,
226                    field_name_conditions,
227                    field_name_cond_not,
228                );
229                Ok(true)
230            }
231
232            Transformation::AddCondition {
233                conditions,
234                negated,
235            } => {
236                add_conditions(rule, conditions, *negated);
237                Ok(true)
238            }
239
240            Transformation::ChangeLogsource {
241                category,
242                product,
243                service,
244            } => {
245                if let Some(cat) = category {
246                    rule.logsource.category = Some(cat.clone());
247                }
248                if let Some(prod) = product {
249                    rule.logsource.product = Some(prod.clone());
250                }
251                if let Some(svc) = service {
252                    rule.logsource.service = Some(svc.clone());
253                }
254                Ok(true)
255            }
256
257            Transformation::ReplaceString {
258                regex,
259                replacement,
260                skip_special,
261            } => {
262                let re = Regex::new(regex)
263                    .map_err(|e| EvalError::InvalidModifiers(format!("bad regex: {e}")))?;
264                replace_strings_in_rule(
265                    rule,
266                    state,
267                    detection_item_conditions,
268                    field_name_conditions,
269                    field_name_cond_not,
270                    &re,
271                    replacement,
272                    *skip_special,
273                );
274                Ok(true)
275            }
276
277            Transformation::ValuePlaceholders => {
278                expand_placeholders_in_rule(rule, state, false);
279                Ok(true)
280            }
281
282            Transformation::WildcardPlaceholders => {
283                expand_placeholders_in_rule(rule, state, true);
284                Ok(true)
285            }
286
287            Transformation::QueryExpressionPlaceholders { .. } => {
288                // No-op for eval mode
289                Ok(false)
290            }
291
292            Transformation::SetState { key, value } => {
293                state.set_state(key.clone(), serde_json::Value::String(value.clone()));
294                Ok(true)
295            }
296
297            Transformation::RuleFailure { message } => Err(EvalError::InvalidModifiers(format!(
298                "Pipeline rule failure: {message} (rule: {})",
299                rule.title
300            ))),
301
302            Transformation::DetectionItemFailure { message } => {
303                // Check if any detection item matches the conditions
304                let has_match = rule_has_matching_item(rule, state, detection_item_conditions);
305                if has_match {
306                    Err(EvalError::InvalidModifiers(format!(
307                        "Pipeline detection item failure: {message} (rule: {})",
308                        rule.title
309                    )))
310                } else {
311                    Ok(false)
312                }
313            }
314
315            Transformation::FieldNameTransform {
316                transform_func,
317                mapping,
318            } => {
319                let func = transform_func.clone();
320                let map = mapping.clone();
321                apply_field_name_transform(
322                    rule,
323                    state,
324                    field_name_conditions,
325                    field_name_cond_not,
326                    |name| {
327                        if let Some(mapped) = map.get(name) {
328                            return Some(mapped.clone());
329                        }
330                        Some(apply_named_string_fn(&func, name))
331                    },
332                );
333                Ok(true)
334            }
335
336            Transformation::HashesFields {
337                valid_hash_algos,
338                field_prefix,
339                drop_algo_prefix,
340            } => {
341                decompose_hashes_field(rule, valid_hash_algos, field_prefix, *drop_algo_prefix);
342                Ok(true)
343            }
344
345            Transformation::MapString { mapping } => {
346                map_string_values(
347                    rule,
348                    state,
349                    detection_item_conditions,
350                    field_name_conditions,
351                    field_name_cond_not,
352                    mapping,
353                );
354                Ok(true)
355            }
356
357            Transformation::SetValue { value } => {
358                set_detection_item_values(
359                    rule,
360                    state,
361                    detection_item_conditions,
362                    field_name_conditions,
363                    field_name_cond_not,
364                    value,
365                );
366                Ok(true)
367            }
368
369            Transformation::ConvertType { target_type } => {
370                convert_detection_item_types(
371                    rule,
372                    state,
373                    detection_item_conditions,
374                    field_name_conditions,
375                    field_name_cond_not,
376                    target_type,
377                );
378                Ok(true)
379            }
380
381            Transformation::Regex => {
382                // No-op: marking that plain strings should be treated as regex.
383                // In eval mode all matching goes through our compiled matchers,
384                // so there is nothing to mutate. Kept for YAML compat.
385                Ok(false)
386            }
387
388            Transformation::AddField { field } => {
389                if !rule.fields.contains(field) {
390                    rule.fields.push(field.clone());
391                }
392                Ok(true)
393            }
394
395            Transformation::RemoveField { field } => {
396                rule.fields.retain(|f| f != field);
397                Ok(true)
398            }
399
400            Transformation::SetField { fields } => {
401                rule.fields = fields.clone();
402                Ok(true)
403            }
404
405            Transformation::SetCustomAttribute { attribute, value } => {
406                rule.custom_attributes
407                    .insert(attribute.clone(), value.clone());
408                Ok(true)
409            }
410
411            Transformation::CaseTransformation { case_type } => {
412                apply_case_transformation(
413                    rule,
414                    state,
415                    detection_item_conditions,
416                    field_name_conditions,
417                    field_name_cond_not,
418                    case_type,
419                );
420                Ok(true)
421            }
422
423            Transformation::Nest { items } => {
424                for item in items {
425                    // Merge conditions: item's own + parent's
426                    let mut merged_det_conds: Vec<DetectionItemCondition> =
427                        detection_item_conditions.to_vec();
428                    merged_det_conds.extend(item.detection_item_conditions.clone());
429
430                    let mut merged_field_conds: Vec<FieldNameCondition> =
431                        field_name_conditions.to_vec();
432                    merged_field_conds.extend(item.field_name_conditions.clone());
433
434                    // Evaluate rule conditions
435                    let rule_ok = if item.rule_conditions.is_empty() {
436                        true
437                    } else {
438                        super::conditions::all_rule_conditions_match(
439                            &item.rule_conditions,
440                            rule,
441                            state,
442                        )
443                    };
444
445                    if rule_ok {
446                        item.transformation.apply(
447                            rule,
448                            state,
449                            &merged_det_conds,
450                            &merged_field_conds,
451                            item.field_name_cond_not || field_name_cond_not,
452                        )?;
453                        if let Some(ref id) = item.id {
454                            state.mark_applied(id);
455                        }
456                    }
457                }
458                Ok(true)
459            }
460        }
461    }
462}
463
464// =============================================================================
465// Field name transformation helper
466// =============================================================================
467
468fn apply_field_name_transform<F>(
469    rule: &mut SigmaRule,
470    state: &PipelineState,
471    field_name_conditions: &[FieldNameCondition],
472    field_name_cond_not: bool,
473    transform_fn: F,
474) where
475    F: Fn(&str) -> Option<String>,
476{
477    for detection in rule.detection.named.values_mut() {
478        transform_detection_fields(
479            detection,
480            state,
481            field_name_conditions,
482            field_name_cond_not,
483            &transform_fn,
484        );
485    }
486}
487
488fn transform_detection_fields<F>(
489    detection: &mut Detection,
490    state: &PipelineState,
491    field_name_conditions: &[FieldNameCondition],
492    field_name_cond_not: bool,
493    transform_fn: &F,
494) where
495    F: Fn(&str) -> Option<String>,
496{
497    match detection {
498        Detection::AllOf(items) => {
499            for item in items.iter_mut() {
500                if let Some(ref name) = item.field.name
501                    && field_conditions_match(
502                        name,
503                        state,
504                        field_name_conditions,
505                        field_name_cond_not,
506                    )
507                    && let Some(new_name) = transform_fn(name)
508                {
509                    item.field.name = Some(new_name);
510                }
511            }
512        }
513        Detection::AnyOf(subs) => {
514            for sub in subs.iter_mut() {
515                transform_detection_fields(
516                    sub,
517                    state,
518                    field_name_conditions,
519                    field_name_cond_not,
520                    transform_fn,
521                );
522            }
523        }
524        Detection::Keywords(_) => {}
525    }
526}
527
528fn field_conditions_match(
529    field_name: &str,
530    state: &PipelineState,
531    conditions: &[FieldNameCondition],
532    negate: bool,
533) -> bool {
534    if conditions.is_empty() {
535        return true;
536    }
537    let all_match = conditions
538        .iter()
539        .all(|c| c.matches_field_name(field_name, state));
540    if negate { !all_match } else { all_match }
541}
542
543// =============================================================================
544// Drop detection items
545// =============================================================================
546
547fn drop_detection_items(
548    rule: &mut SigmaRule,
549    state: &PipelineState,
550    detection_conditions: &[DetectionItemCondition],
551    field_name_conditions: &[FieldNameCondition],
552    field_name_cond_not: bool,
553) {
554    for detection in rule.detection.named.values_mut() {
555        drop_from_detection(
556            detection,
557            state,
558            detection_conditions,
559            field_name_conditions,
560            field_name_cond_not,
561        );
562    }
563}
564
565fn drop_from_detection(
566    detection: &mut Detection,
567    state: &PipelineState,
568    detection_conditions: &[DetectionItemCondition],
569    field_name_conditions: &[FieldNameCondition],
570    field_name_cond_not: bool,
571) {
572    match detection {
573        Detection::AllOf(items) => {
574            items.retain(|item| {
575                !should_drop_item(
576                    item,
577                    state,
578                    detection_conditions,
579                    field_name_conditions,
580                    field_name_cond_not,
581                )
582            });
583        }
584        Detection::AnyOf(subs) => {
585            for sub in subs.iter_mut() {
586                drop_from_detection(
587                    sub,
588                    state,
589                    detection_conditions,
590                    field_name_conditions,
591                    field_name_cond_not,
592                );
593            }
594        }
595        Detection::Keywords(_) => {}
596    }
597}
598
599fn should_drop_item(
600    item: &DetectionItem,
601    state: &PipelineState,
602    detection_conditions: &[DetectionItemCondition],
603    field_name_conditions: &[FieldNameCondition],
604    field_name_cond_not: bool,
605) -> bool {
606    // Check detection item conditions
607    let det_match = detection_conditions.is_empty()
608        || detection_conditions
609            .iter()
610            .all(|c| c.matches_item(item, state));
611
612    // Check field name conditions
613    let field_match = if let Some(ref name) = item.field.name {
614        field_conditions_match(name, state, field_name_conditions, field_name_cond_not)
615    } else {
616        field_name_conditions.is_empty()
617    };
618
619    det_match && field_match
620}
621
622// =============================================================================
623// Add conditions
624// =============================================================================
625
626fn add_conditions(rule: &mut SigmaRule, conditions: &HashMap<String, SigmaValue>, negated: bool) {
627    // Create a new detection with the given conditions
628    let items: Vec<DetectionItem> = conditions
629        .iter()
630        .map(|(field, value)| DetectionItem {
631            field: FieldSpec::new(Some(field.clone()), Vec::new()),
632            values: vec![value.clone()],
633        })
634        .collect();
635
636    let det_name = format!("__pipeline_cond_{}", rule.detection.named.len());
637    rule.detection
638        .named
639        .insert(det_name.clone(), Detection::AllOf(items));
640
641    // Add to existing conditions: AND (or AND NOT if negated)
642    let cond_ref = ConditionExpr::Identifier(det_name);
643    let cond_expr = if negated {
644        ConditionExpr::Not(Box::new(cond_ref))
645    } else {
646        cond_ref
647    };
648
649    rule.detection.conditions = rule
650        .detection
651        .conditions
652        .iter()
653        .map(|existing| ConditionExpr::And(vec![existing.clone(), cond_expr.clone()]))
654        .collect();
655}
656
657// =============================================================================
658// Replace strings
659// =============================================================================
660
661#[allow(clippy::too_many_arguments)]
662fn replace_strings_in_rule(
663    rule: &mut SigmaRule,
664    state: &PipelineState,
665    detection_conditions: &[DetectionItemCondition],
666    field_name_conditions: &[FieldNameCondition],
667    field_name_cond_not: bool,
668    re: &Regex,
669    replacement: &str,
670    skip_special: bool,
671) {
672    for detection in rule.detection.named.values_mut() {
673        replace_strings_in_detection(
674            detection,
675            state,
676            detection_conditions,
677            field_name_conditions,
678            field_name_cond_not,
679            re,
680            replacement,
681            skip_special,
682        );
683    }
684}
685
686#[allow(clippy::too_many_arguments)]
687fn replace_strings_in_detection(
688    detection: &mut Detection,
689    state: &PipelineState,
690    detection_conditions: &[DetectionItemCondition],
691    field_name_conditions: &[FieldNameCondition],
692    field_name_cond_not: bool,
693    re: &Regex,
694    replacement: &str,
695    skip_special: bool,
696) {
697    match detection {
698        Detection::AllOf(items) => {
699            for item in items.iter_mut() {
700                let det_match = detection_conditions.is_empty()
701                    || detection_conditions
702                        .iter()
703                        .all(|c| c.matches_item(item, state));
704                let field_match = if let Some(ref name) = item.field.name {
705                    field_conditions_match(name, state, field_name_conditions, field_name_cond_not)
706                } else {
707                    field_name_conditions.is_empty()
708                };
709
710                if det_match && field_match {
711                    replace_strings_in_values(&mut item.values, re, replacement, skip_special);
712                }
713            }
714        }
715        Detection::AnyOf(subs) => {
716            for sub in subs.iter_mut() {
717                replace_strings_in_detection(
718                    sub,
719                    state,
720                    detection_conditions,
721                    field_name_conditions,
722                    field_name_cond_not,
723                    re,
724                    replacement,
725                    skip_special,
726                );
727            }
728        }
729        Detection::Keywords(values) => {
730            replace_strings_in_values(values, re, replacement, skip_special);
731        }
732    }
733}
734
735fn replace_strings_in_values(
736    values: &mut [SigmaValue],
737    re: &Regex,
738    replacement: &str,
739    skip_special: bool,
740) {
741    for value in values.iter_mut() {
742        if let SigmaValue::String(s) = value {
743            if skip_special && s.contains_wildcards() {
744                // Replace only in plain segments, preserving wildcards
745                let new_parts: Vec<StringPart> = s
746                    .parts
747                    .iter()
748                    .map(|part| match part {
749                        StringPart::Plain(text) => {
750                            let replaced = re.replace_all(text, replacement);
751                            StringPart::Plain(replaced.into_owned())
752                        }
753                        special => special.clone(),
754                    })
755                    .collect();
756                if new_parts != s.parts {
757                    // Rebuild original string from the new parts
758                    let new_original = parts_to_original(&new_parts);
759                    s.parts = new_parts;
760                    s.original = new_original;
761                }
762            } else {
763                let replaced = re.replace_all(&s.original, replacement);
764                if replaced != s.original {
765                    *s = SigmaString::new(&replaced);
766                }
767            }
768        }
769    }
770}
771
772/// Reconstruct the `original` string from parts, re-escaping wildcards.
773fn parts_to_original(parts: &[StringPart]) -> String {
774    let mut out = String::new();
775    for part in parts {
776        match part {
777            StringPart::Plain(text) => {
778                // Escape special chars so round-trip through SigmaString::new works
779                for c in text.chars() {
780                    if c == '*' || c == '?' || c == '\\' {
781                        out.push('\\');
782                    }
783                    out.push(c);
784                }
785            }
786            StringPart::Special(SpecialChar::WildcardMulti) => out.push('*'),
787            StringPart::Special(SpecialChar::WildcardSingle) => out.push('?'),
788        }
789    }
790    out
791}
792
793// =============================================================================
794// Placeholder expansion
795// =============================================================================
796
797fn expand_placeholders_in_rule(rule: &mut SigmaRule, state: &PipelineState, wildcard: bool) {
798    for detection in rule.detection.named.values_mut() {
799        expand_placeholders_in_detection(detection, state, wildcard);
800    }
801}
802
803fn expand_placeholders_in_detection(
804    detection: &mut Detection,
805    state: &PipelineState,
806    wildcard: bool,
807) {
808    match detection {
809        Detection::AllOf(items) => {
810            for item in items.iter_mut() {
811                expand_placeholders_in_values(&mut item.values, state, wildcard);
812            }
813        }
814        Detection::AnyOf(subs) => {
815            for sub in subs.iter_mut() {
816                expand_placeholders_in_detection(sub, state, wildcard);
817            }
818        }
819        Detection::Keywords(values) => {
820            expand_placeholders_in_values(values, state, wildcard);
821        }
822    }
823}
824
825fn expand_placeholders_in_values(
826    values: &mut Vec<SigmaValue>,
827    state: &PipelineState,
828    wildcard: bool,
829) {
830    let mut expanded_values = Vec::new();
831    for value in values.drain(..) {
832        if let SigmaValue::String(ref s) = value {
833            let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
834            if plain.contains('%') {
835                // Try to expand %name% patterns
836                let result = expand_placeholder_string(&plain, state, wildcard);
837                expanded_values.extend(result);
838                continue;
839            }
840        }
841        expanded_values.push(value);
842    }
843    *values = expanded_values;
844}
845
846fn expand_placeholder_string(s: &str, state: &PipelineState, wildcard: bool) -> Vec<SigmaValue> {
847    // Find %name% patterns
848    let mut result = s.to_string();
849    let mut has_unresolved = false;
850
851    // Simple regex-free pattern matching for %name%
852    while let Some(start) = result.find('%') {
853        let rest = &result[start + 1..];
854        let Some(end) = rest.find('%') else {
855            break;
856        };
857        let placeholder = &rest[..end];
858
859        if let Some(values) = state.vars.get(placeholder) {
860            if values.len() == 1 {
861                result = format!("{}{}{}", &result[..start], values[0], &rest[end + 1..]);
862            } else if values.is_empty() {
863                if wildcard {
864                    result = format!("{}*{}", &result[..start], &rest[end + 1..]);
865                } else {
866                    has_unresolved = true;
867                    break;
868                }
869            } else {
870                // Multiple values: create one SigmaValue per expansion
871                return values
872                    .iter()
873                    .map(|v| {
874                        let expanded = format!("{}{}{}", &result[..start], v, &rest[end + 1..]);
875                        SigmaValue::String(SigmaString::new(&expanded))
876                    })
877                    .collect();
878            }
879        } else if wildcard {
880            result = format!("{}*{}", &result[..start], &rest[end + 1..]);
881        } else {
882            has_unresolved = true;
883            break;
884        }
885    }
886
887    if has_unresolved && wildcard {
888        // Replace remaining unresolved placeholders with *
889        // This is a simplistic approach
890        vec![SigmaValue::String(SigmaString::new(&result))]
891    } else {
892        vec![SigmaValue::String(SigmaString::new(&result))]
893    }
894}
895
896// =============================================================================
897// Named string function helper (for FieldNameTransform)
898// =============================================================================
899
900fn apply_named_string_fn(func: &str, s: &str) -> String {
901    match func {
902        "lower" | "lowercase" => s.to_lowercase(),
903        "upper" | "uppercase" => s.to_uppercase(),
904        "title" => {
905            // Capitalize first letter of each word
906            s.split(|c: char| !c.is_alphanumeric())
907                .filter(|w| !w.is_empty())
908                .map(|w| {
909                    let mut c = w.chars();
910                    match c.next() {
911                        None => String::new(),
912                        Some(f) => {
913                            f.to_uppercase().collect::<String>() + &c.as_str().to_lowercase()
914                        }
915                    }
916                })
917                .collect::<Vec<_>>()
918                .join("_")
919        }
920        "snake_case" => {
921            // Simple camelCase / PascalCase → snake_case
922            let mut out = String::new();
923            for (i, ch) in s.chars().enumerate() {
924                if ch.is_uppercase() && i > 0 {
925                    out.push('_');
926                }
927                out.push(ch.to_lowercase().next().unwrap_or(ch));
928            }
929            out
930        }
931        _ => s.to_string(), // unknown function → identity
932    }
933}
934
935// =============================================================================
936// Hashes field decomposition
937// =============================================================================
938
939fn decompose_hashes_field(
940    rule: &mut SigmaRule,
941    valid_algos: &[String],
942    field_prefix: &str,
943    drop_algo_prefix: bool,
944) {
945    for detection in rule.detection.named.values_mut() {
946        decompose_hashes_in_detection(detection, valid_algos, field_prefix, drop_algo_prefix);
947    }
948}
949
950fn decompose_hashes_in_detection(
951    detection: &mut Detection,
952    valid_algos: &[String],
953    field_prefix: &str,
954    drop_algo_prefix: bool,
955) {
956    match detection {
957        Detection::AllOf(items) => {
958            let mut new_items: Vec<DetectionItem> = Vec::new();
959            let mut i = 0;
960            while i < items.len() {
961                let item = &items[i];
962                let is_hashes = item
963                    .field
964                    .name
965                    .as_deref()
966                    .map(|n| n.eq_ignore_ascii_case("hashes"))
967                    .unwrap_or(false);
968
969                if is_hashes {
970                    // Decompose each value "ALGO=HASH"
971                    for val in &item.values {
972                        if let SigmaValue::String(s) = val {
973                            let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
974                            for pair in plain.split(',') {
975                                let pair = pair.trim();
976                                if let Some((algo, hash)) = pair.split_once('=') {
977                                    let algo_upper = algo.trim().to_uppercase();
978                                    if valid_algos.is_empty()
979                                        || valid_algos
980                                            .iter()
981                                            .any(|a| a.eq_ignore_ascii_case(&algo_upper))
982                                    {
983                                        let field_name = if drop_algo_prefix {
984                                            field_prefix.to_string()
985                                        } else {
986                                            format!("{field_prefix}{}", algo.trim())
987                                        };
988                                        new_items.push(DetectionItem {
989                                            field: FieldSpec::new(
990                                                Some(field_name),
991                                                item.field.modifiers.clone(),
992                                            ),
993                                            values: vec![SigmaValue::String(SigmaString::new(
994                                                hash.trim(),
995                                            ))],
996                                        });
997                                    }
998                                }
999                            }
1000                        }
1001                    }
1002                } else {
1003                    new_items.push(items[i].clone());
1004                }
1005                i += 1;
1006            }
1007            *items = new_items;
1008        }
1009        Detection::AnyOf(subs) => {
1010            for sub in subs.iter_mut() {
1011                decompose_hashes_in_detection(sub, valid_algos, field_prefix, drop_algo_prefix);
1012            }
1013        }
1014        Detection::Keywords(_) => {}
1015    }
1016}
1017
1018// =============================================================================
1019// Map string values
1020// =============================================================================
1021
1022fn map_string_values(
1023    rule: &mut SigmaRule,
1024    state: &PipelineState,
1025    detection_conditions: &[DetectionItemCondition],
1026    field_name_conditions: &[FieldNameCondition],
1027    field_name_cond_not: bool,
1028    mapping: &HashMap<String, Vec<String>>,
1029) {
1030    for detection in rule.detection.named.values_mut() {
1031        map_strings_in_detection(
1032            detection,
1033            state,
1034            detection_conditions,
1035            field_name_conditions,
1036            field_name_cond_not,
1037            mapping,
1038        );
1039    }
1040}
1041
1042fn map_strings_in_detection(
1043    detection: &mut Detection,
1044    state: &PipelineState,
1045    detection_conditions: &[DetectionItemCondition],
1046    field_name_conditions: &[FieldNameCondition],
1047    field_name_cond_not: bool,
1048    mapping: &HashMap<String, Vec<String>>,
1049) {
1050    match detection {
1051        Detection::AllOf(items) => {
1052            for item in items.iter_mut() {
1053                if item_conditions_match(
1054                    item,
1055                    state,
1056                    detection_conditions,
1057                    field_name_conditions,
1058                    field_name_cond_not,
1059                ) {
1060                    map_string_expand_values(&mut item.values, mapping);
1061                }
1062            }
1063        }
1064        Detection::AnyOf(subs) => {
1065            for sub in subs.iter_mut() {
1066                map_strings_in_detection(
1067                    sub,
1068                    state,
1069                    detection_conditions,
1070                    field_name_conditions,
1071                    field_name_cond_not,
1072                    mapping,
1073                );
1074            }
1075        }
1076        Detection::Keywords(values) => {
1077            map_string_expand_values(values, mapping);
1078        }
1079    }
1080}
1081
1082/// Map string values with one-to-many support.
1083///
1084/// When a mapping entry has multiple replacements (e.g. `"foo": ["bar", "baz"]`),
1085/// the original value is replaced with the first alternative and additional
1086/// alternatives are appended to the values list. This expands the detection
1087/// item's value list, matching pySigma's `MapStringTransformation` behavior.
1088fn map_string_expand_values(values: &mut Vec<SigmaValue>, mapping: &HashMap<String, Vec<String>>) {
1089    let mut extra: Vec<(usize, Vec<SigmaValue>)> = Vec::new();
1090
1091    for (i, val) in values.iter_mut().enumerate() {
1092        if let SigmaValue::String(s) = val {
1093            let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
1094            if let Some(replacements) = mapping.get(&plain) {
1095                if let Some(first) = replacements.first() {
1096                    *s = SigmaString::new(first);
1097                }
1098                // Collect extra alternatives (2nd, 3rd, …) for later insertion
1099                if replacements.len() > 1 {
1100                    let extras: Vec<SigmaValue> = replacements[1..]
1101                        .iter()
1102                        .map(|r| SigmaValue::String(SigmaString::new(r)))
1103                        .collect();
1104                    extra.push((i, extras));
1105                }
1106            }
1107        }
1108    }
1109
1110    // Insert extra values in reverse order so indices remain valid
1111    for (idx, extras) in extra.into_iter().rev() {
1112        for (j, v) in extras.into_iter().enumerate() {
1113            values.insert(idx + 1 + j, v);
1114        }
1115    }
1116}
1117
1118// =============================================================================
1119// Set value
1120// =============================================================================
1121
1122fn set_detection_item_values(
1123    rule: &mut SigmaRule,
1124    state: &PipelineState,
1125    detection_conditions: &[DetectionItemCondition],
1126    field_name_conditions: &[FieldNameCondition],
1127    field_name_cond_not: bool,
1128    value: &SigmaValue,
1129) {
1130    for detection in rule.detection.named.values_mut() {
1131        set_values_in_detection(
1132            detection,
1133            state,
1134            detection_conditions,
1135            field_name_conditions,
1136            field_name_cond_not,
1137            value,
1138        );
1139    }
1140}
1141
1142fn set_values_in_detection(
1143    detection: &mut Detection,
1144    state: &PipelineState,
1145    detection_conditions: &[DetectionItemCondition],
1146    field_name_conditions: &[FieldNameCondition],
1147    field_name_cond_not: bool,
1148    value: &SigmaValue,
1149) {
1150    match detection {
1151        Detection::AllOf(items) => {
1152            for item in items.iter_mut() {
1153                if item_conditions_match(
1154                    item,
1155                    state,
1156                    detection_conditions,
1157                    field_name_conditions,
1158                    field_name_cond_not,
1159                ) {
1160                    item.values = vec![value.clone()];
1161                }
1162            }
1163        }
1164        Detection::AnyOf(subs) => {
1165            for sub in subs.iter_mut() {
1166                set_values_in_detection(
1167                    sub,
1168                    state,
1169                    detection_conditions,
1170                    field_name_conditions,
1171                    field_name_cond_not,
1172                    value,
1173                );
1174            }
1175        }
1176        Detection::Keywords(_) => {}
1177    }
1178}
1179
1180// =============================================================================
1181// Convert type
1182// =============================================================================
1183
1184fn convert_detection_item_types(
1185    rule: &mut SigmaRule,
1186    state: &PipelineState,
1187    detection_conditions: &[DetectionItemCondition],
1188    field_name_conditions: &[FieldNameCondition],
1189    field_name_cond_not: bool,
1190    target_type: &str,
1191) {
1192    for detection in rule.detection.named.values_mut() {
1193        convert_types_in_detection(
1194            detection,
1195            state,
1196            detection_conditions,
1197            field_name_conditions,
1198            field_name_cond_not,
1199            target_type,
1200        );
1201    }
1202}
1203
1204fn convert_types_in_detection(
1205    detection: &mut Detection,
1206    state: &PipelineState,
1207    detection_conditions: &[DetectionItemCondition],
1208    field_name_conditions: &[FieldNameCondition],
1209    field_name_cond_not: bool,
1210    target_type: &str,
1211) {
1212    match detection {
1213        Detection::AllOf(items) => {
1214            for item in items.iter_mut() {
1215                if item_conditions_match(
1216                    item,
1217                    state,
1218                    detection_conditions,
1219                    field_name_conditions,
1220                    field_name_cond_not,
1221                ) {
1222                    for val in item.values.iter_mut() {
1223                        *val = convert_value(val, target_type);
1224                    }
1225                }
1226            }
1227        }
1228        Detection::AnyOf(subs) => {
1229            for sub in subs.iter_mut() {
1230                convert_types_in_detection(
1231                    sub,
1232                    state,
1233                    detection_conditions,
1234                    field_name_conditions,
1235                    field_name_cond_not,
1236                    target_type,
1237                );
1238            }
1239        }
1240        Detection::Keywords(_) => {}
1241    }
1242}
1243
1244fn convert_value(val: &SigmaValue, target: &str) -> SigmaValue {
1245    match target {
1246        "str" | "string" => match val {
1247            SigmaValue::String(_) => val.clone(),
1248            SigmaValue::Integer(n) => SigmaValue::String(SigmaString::new(&n.to_string())),
1249            SigmaValue::Float(f) => SigmaValue::String(SigmaString::new(&f.to_string())),
1250            SigmaValue::Bool(b) => SigmaValue::String(SigmaString::new(&b.to_string())),
1251            SigmaValue::Null => SigmaValue::String(SigmaString::new("null")),
1252        },
1253        "int" | "integer" => match val {
1254            SigmaValue::String(s) => {
1255                let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
1256                plain
1257                    .parse::<i64>()
1258                    .map(SigmaValue::Integer)
1259                    .unwrap_or_else(|_| val.clone())
1260            }
1261            SigmaValue::Float(f) => SigmaValue::Integer(*f as i64),
1262            SigmaValue::Bool(b) => SigmaValue::Integer(if *b { 1 } else { 0 }),
1263            _ => val.clone(),
1264        },
1265        "float" => match val {
1266            SigmaValue::String(s) => {
1267                let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
1268                plain
1269                    .parse::<f64>()
1270                    .map(SigmaValue::Float)
1271                    .unwrap_or_else(|_| val.clone())
1272            }
1273            SigmaValue::Integer(n) => SigmaValue::Float(*n as f64),
1274            SigmaValue::Bool(b) => SigmaValue::Float(if *b { 1.0 } else { 0.0 }),
1275            _ => val.clone(),
1276        },
1277        "bool" | "boolean" => match val {
1278            SigmaValue::String(s) => {
1279                let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
1280                match plain.to_lowercase().as_str() {
1281                    "true" | "1" | "yes" => SigmaValue::Bool(true),
1282                    "false" | "0" | "no" => SigmaValue::Bool(false),
1283                    _ => val.clone(),
1284                }
1285            }
1286            SigmaValue::Integer(n) => SigmaValue::Bool(*n != 0),
1287            SigmaValue::Float(f) => SigmaValue::Bool(*f != 0.0),
1288            _ => val.clone(),
1289        },
1290        _ => val.clone(),
1291    }
1292}
1293
1294// =============================================================================
1295// Case transformation
1296// =============================================================================
1297
1298fn apply_case_transformation(
1299    rule: &mut SigmaRule,
1300    state: &PipelineState,
1301    detection_conditions: &[DetectionItemCondition],
1302    field_name_conditions: &[FieldNameCondition],
1303    field_name_cond_not: bool,
1304    case_type: &str,
1305) {
1306    for detection in rule.detection.named.values_mut() {
1307        apply_case_in_detection(
1308            detection,
1309            state,
1310            detection_conditions,
1311            field_name_conditions,
1312            field_name_cond_not,
1313            case_type,
1314        );
1315    }
1316}
1317
1318fn apply_case_in_detection(
1319    detection: &mut Detection,
1320    state: &PipelineState,
1321    detection_conditions: &[DetectionItemCondition],
1322    field_name_conditions: &[FieldNameCondition],
1323    field_name_cond_not: bool,
1324    case_type: &str,
1325) {
1326    match detection {
1327        Detection::AllOf(items) => {
1328            for item in items.iter_mut() {
1329                if item_conditions_match(
1330                    item,
1331                    state,
1332                    detection_conditions,
1333                    field_name_conditions,
1334                    field_name_cond_not,
1335                ) {
1336                    for val in item.values.iter_mut() {
1337                        apply_case_to_value(val, case_type);
1338                    }
1339                }
1340            }
1341        }
1342        Detection::AnyOf(subs) => {
1343            for sub in subs.iter_mut() {
1344                apply_case_in_detection(
1345                    sub,
1346                    state,
1347                    detection_conditions,
1348                    field_name_conditions,
1349                    field_name_cond_not,
1350                    case_type,
1351                );
1352            }
1353        }
1354        Detection::Keywords(values) => {
1355            for val in values.iter_mut() {
1356                apply_case_to_value(val, case_type);
1357            }
1358        }
1359    }
1360}
1361
1362fn apply_case_to_value(val: &mut SigmaValue, case_type: &str) {
1363    if let SigmaValue::String(s) = val {
1364        let transformed = match case_type {
1365            "lower" | "lowercase" => s.original.to_lowercase(),
1366            "upper" | "uppercase" => s.original.to_uppercase(),
1367            "snake_case" => apply_named_string_fn("snake_case", &s.original),
1368            _ => return,
1369        };
1370        if transformed != s.original {
1371            *s = SigmaString::new(&transformed);
1372        }
1373    }
1374}
1375
1376// =============================================================================
1377// Shared helper: check if a detection item matches both sets of conditions
1378// =============================================================================
1379
1380fn item_conditions_match(
1381    item: &DetectionItem,
1382    state: &PipelineState,
1383    detection_conditions: &[DetectionItemCondition],
1384    field_name_conditions: &[FieldNameCondition],
1385    field_name_cond_not: bool,
1386) -> bool {
1387    let det_match = detection_conditions.is_empty()
1388        || detection_conditions
1389            .iter()
1390            .all(|c| c.matches_item(item, state));
1391
1392    let field_match = if let Some(ref name) = item.field.name {
1393        field_conditions_match(name, state, field_name_conditions, field_name_cond_not)
1394    } else {
1395        field_name_conditions.is_empty()
1396    };
1397
1398    det_match && field_match
1399}
1400
1401// =============================================================================
1402// Helper: check if rule has any item matching conditions
1403// =============================================================================
1404
1405fn rule_has_matching_item(
1406    rule: &SigmaRule,
1407    state: &PipelineState,
1408    conditions: &[DetectionItemCondition],
1409) -> bool {
1410    for detection in rule.detection.named.values() {
1411        if detection_has_matching_item(detection, state, conditions) {
1412            return true;
1413        }
1414    }
1415    false
1416}
1417
1418fn detection_has_matching_item(
1419    detection: &Detection,
1420    state: &PipelineState,
1421    conditions: &[DetectionItemCondition],
1422) -> bool {
1423    match detection {
1424        Detection::AllOf(items) => items
1425            .iter()
1426            .any(|item| conditions.iter().all(|c| c.matches_item(item, state))),
1427        Detection::AnyOf(subs) => subs
1428            .iter()
1429            .any(|sub| detection_has_matching_item(sub, state, conditions)),
1430        Detection::Keywords(_) => false,
1431    }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437    use rsigma_parser::{Detections, LogSource, Modifier};
1438
1439    fn make_test_rule() -> SigmaRule {
1440        let mut named = HashMap::new();
1441        named.insert(
1442            "selection".to_string(),
1443            Detection::AllOf(vec![
1444                DetectionItem {
1445                    field: FieldSpec::new(
1446                        Some("CommandLine".to_string()),
1447                        vec![Modifier::Contains],
1448                    ),
1449                    values: vec![SigmaValue::String(SigmaString::new("whoami"))],
1450                },
1451                DetectionItem {
1452                    field: FieldSpec::new(
1453                        Some("ParentImage".to_string()),
1454                        vec![Modifier::EndsWith],
1455                    ),
1456                    values: vec![SigmaValue::String(SigmaString::new("\\cmd.exe"))],
1457                },
1458            ]),
1459        );
1460
1461        SigmaRule {
1462            title: "Test Rule".to_string(),
1463            logsource: LogSource {
1464                category: Some("process_creation".to_string()),
1465                product: Some("windows".to_string()),
1466                service: None,
1467                definition: None,
1468                custom: HashMap::new(),
1469            },
1470            detection: Detections {
1471                named,
1472                conditions: vec![ConditionExpr::Identifier("selection".to_string())],
1473                condition_strings: vec!["selection".to_string()],
1474                timeframe: None,
1475            },
1476            id: Some("test-001".to_string()),
1477            name: None,
1478            related: vec![],
1479            taxonomy: None,
1480            status: None,
1481            description: None,
1482            license: None,
1483            author: None,
1484            references: vec![],
1485            date: None,
1486            modified: None,
1487            fields: vec![],
1488            falsepositives: vec![],
1489            level: Some(rsigma_parser::Level::Medium),
1490            tags: vec![],
1491            scope: vec![],
1492            custom_attributes: HashMap::new(),
1493        }
1494    }
1495
1496    #[test]
1497    fn test_field_name_mapping() {
1498        let mut rule = make_test_rule();
1499        let mut state = PipelineState::default();
1500        let mut mapping = HashMap::new();
1501        mapping.insert(
1502            "CommandLine".to_string(),
1503            "process.command_line".to_string(),
1504        );
1505        mapping.insert(
1506            "ParentImage".to_string(),
1507            "process.parent.executable".to_string(),
1508        );
1509
1510        let t = Transformation::FieldNameMapping { mapping };
1511        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1512
1513        let det = &rule.detection.named["selection"];
1514        if let Detection::AllOf(items) = det {
1515            assert_eq!(
1516                items[0].field.name,
1517                Some("process.command_line".to_string())
1518            );
1519            assert_eq!(
1520                items[1].field.name,
1521                Some("process.parent.executable".to_string())
1522            );
1523        } else {
1524            panic!("Expected AllOf");
1525        }
1526    }
1527
1528    #[test]
1529    fn test_field_name_prefix() {
1530        let mut rule = make_test_rule();
1531        let mut state = PipelineState::default();
1532        let t = Transformation::FieldNamePrefix {
1533            prefix: "winlog.event_data.".to_string(),
1534        };
1535        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1536
1537        let det = &rule.detection.named["selection"];
1538        if let Detection::AllOf(items) = det {
1539            assert_eq!(
1540                items[0].field.name,
1541                Some("winlog.event_data.CommandLine".to_string())
1542            );
1543        } else {
1544            panic!("Expected AllOf");
1545        }
1546    }
1547
1548    #[test]
1549    fn test_field_name_suffix() {
1550        let mut rule = make_test_rule();
1551        let mut state = PipelineState::default();
1552        let t = Transformation::FieldNameSuffix {
1553            suffix: ".keyword".to_string(),
1554        };
1555        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1556
1557        let det = &rule.detection.named["selection"];
1558        if let Detection::AllOf(items) = det {
1559            assert_eq!(items[0].field.name, Some("CommandLine.keyword".to_string()));
1560        } else {
1561            panic!("Expected AllOf");
1562        }
1563    }
1564
1565    #[test]
1566    fn test_change_logsource() {
1567        let mut rule = make_test_rule();
1568        let mut state = PipelineState::default();
1569        let t = Transformation::ChangeLogsource {
1570            category: Some("endpoint".to_string()),
1571            product: Some("elastic".to_string()),
1572            service: None,
1573        };
1574        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1575
1576        assert_eq!(rule.logsource.category, Some("endpoint".to_string()));
1577        assert_eq!(rule.logsource.product, Some("elastic".to_string()));
1578    }
1579
1580    #[test]
1581    fn test_replace_string() {
1582        let mut rule = make_test_rule();
1583        let mut state = PipelineState::default();
1584        let t = Transformation::ReplaceString {
1585            regex: r"whoami".to_string(),
1586            replacement: "REPLACED".to_string(),
1587            skip_special: false,
1588        };
1589        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1590
1591        let det = &rule.detection.named["selection"];
1592        if let Detection::AllOf(items) = det {
1593            if let SigmaValue::String(s) = &items[0].values[0] {
1594                assert_eq!(s.original, "REPLACED");
1595            } else {
1596                panic!("Expected String value");
1597            }
1598        } else {
1599            panic!("Expected AllOf");
1600        }
1601    }
1602
1603    #[test]
1604    fn test_add_condition() {
1605        let mut rule = make_test_rule();
1606        let mut state = PipelineState::default();
1607        let mut conds = HashMap::new();
1608        conds.insert(
1609            "index".to_string(),
1610            SigmaValue::String(SigmaString::new("windows-*")),
1611        );
1612        let t = Transformation::AddCondition {
1613            conditions: conds,
1614            negated: false,
1615        };
1616        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1617
1618        // Check that a new detection was added
1619        assert!(
1620            rule.detection
1621                .named
1622                .keys()
1623                .any(|k| k.starts_with("__pipeline_cond_"))
1624        );
1625        // Check that conditions were wrapped
1626        assert_eq!(rule.detection.conditions.len(), 1);
1627        if let ConditionExpr::And(parts) = &rule.detection.conditions[0] {
1628            assert_eq!(parts.len(), 2);
1629        } else {
1630            panic!("Expected And condition");
1631        }
1632    }
1633
1634    #[test]
1635    fn test_set_state() {
1636        let mut rule = make_test_rule();
1637        let mut state = PipelineState::default();
1638        let t = Transformation::SetState {
1639            key: "index".to_string(),
1640            value: "windows".to_string(),
1641        };
1642        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1643        assert!(state.state_matches("index", "windows"));
1644    }
1645
1646    #[test]
1647    fn test_drop_detection_item_with_field_condition() {
1648        let mut rule = make_test_rule();
1649        let mut state = PipelineState::default();
1650
1651        let field_conds = vec![FieldNameCondition::IncludeFields {
1652            matcher: super::super::conditions::FieldMatcher::Plain(vec!["ParentImage".to_string()]),
1653        }];
1654
1655        let t = Transformation::DropDetectionItem;
1656        t.apply(&mut rule, &mut state, &[], &field_conds, false)
1657            .unwrap();
1658
1659        let det = &rule.detection.named["selection"];
1660        if let Detection::AllOf(items) = det {
1661            assert_eq!(items.len(), 1); // ParentImage was dropped
1662            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
1663        } else {
1664            panic!("Expected AllOf");
1665        }
1666    }
1667
1668    #[test]
1669    fn test_field_name_mapping_with_conditions() {
1670        let mut rule = make_test_rule();
1671        let mut state = PipelineState::default();
1672
1673        // Only map CommandLine, not ParentImage
1674        let field_conds = vec![FieldNameCondition::IncludeFields {
1675            matcher: super::super::conditions::FieldMatcher::Plain(vec!["CommandLine".to_string()]),
1676        }];
1677
1678        let mut mapping = HashMap::new();
1679        mapping.insert("CommandLine".to_string(), "process.args".to_string());
1680        mapping.insert("ParentImage".to_string(), "process.parent".to_string());
1681
1682        let t = Transformation::FieldNameMapping { mapping };
1683        t.apply(&mut rule, &mut state, &[], &field_conds, false)
1684            .unwrap();
1685
1686        let det = &rule.detection.named["selection"];
1687        if let Detection::AllOf(items) = det {
1688            assert_eq!(items[0].field.name, Some("process.args".to_string()));
1689            // ParentImage should NOT have been mapped (field condition didn't match)
1690            assert_eq!(items[1].field.name, Some("ParentImage".to_string()));
1691        } else {
1692            panic!("Expected AllOf");
1693        }
1694    }
1695
1696    #[test]
1697    fn test_rule_failure() {
1698        let mut rule = make_test_rule();
1699        let mut state = PipelineState::default();
1700        let t = Transformation::RuleFailure {
1701            message: "Unsupported rule".to_string(),
1702        };
1703        let result = t.apply(&mut rule, &mut state, &[], &[], false);
1704        assert!(result.is_err());
1705    }
1706
1707    #[test]
1708    fn test_value_placeholders() {
1709        let mut named = HashMap::new();
1710        named.insert(
1711            "selection".to_string(),
1712            Detection::AllOf(vec![DetectionItem {
1713                field: FieldSpec::new(Some("User".to_string()), vec![]),
1714                values: vec![SigmaValue::String(SigmaString::new("%admin_users%"))],
1715            }]),
1716        );
1717
1718        let mut rule = SigmaRule {
1719            title: "Test".to_string(),
1720            logsource: LogSource::default(),
1721            detection: Detections {
1722                named,
1723                conditions: vec![ConditionExpr::Identifier("selection".to_string())],
1724                condition_strings: vec!["selection".to_string()],
1725                timeframe: None,
1726            },
1727            id: None,
1728            name: None,
1729            related: vec![],
1730            taxonomy: None,
1731            status: None,
1732            description: None,
1733            license: None,
1734            author: None,
1735            references: vec![],
1736            date: None,
1737            modified: None,
1738            fields: vec![],
1739            falsepositives: vec![],
1740            level: None,
1741            tags: vec![],
1742            scope: vec![],
1743            custom_attributes: HashMap::new(),
1744        };
1745
1746        let mut state = PipelineState::default();
1747        state.vars.insert(
1748            "admin_users".to_string(),
1749            vec!["root".to_string(), "admin".to_string()],
1750        );
1751
1752        let t = Transformation::ValuePlaceholders;
1753        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1754
1755        let det = &rule.detection.named["selection"];
1756        if let Detection::AllOf(items) = det {
1757            // Should have expanded to 2 values
1758            assert_eq!(items[0].values.len(), 2);
1759        } else {
1760            panic!("Expected AllOf");
1761        }
1762    }
1763
1764    #[test]
1765    fn test_field_name_transform_lowercase() {
1766        let mut rule = make_test_rule();
1767        let mut state = PipelineState::default();
1768        let t = Transformation::FieldNameTransform {
1769            transform_func: "lower".to_string(),
1770            mapping: HashMap::new(),
1771        };
1772        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1773
1774        let det = &rule.detection.named["selection"];
1775        if let Detection::AllOf(items) = det {
1776            assert_eq!(items[0].field.name, Some("commandline".to_string()));
1777            assert_eq!(items[1].field.name, Some("parentimage".to_string()));
1778        } else {
1779            panic!("Expected AllOf");
1780        }
1781    }
1782
1783    #[test]
1784    fn test_field_name_transform_with_mapping_override() {
1785        let mut rule = make_test_rule();
1786        let mut state = PipelineState::default();
1787        let mut mapping = HashMap::new();
1788        mapping.insert("CommandLine".to_string(), "cmd_line".to_string());
1789        let t = Transformation::FieldNameTransform {
1790            transform_func: "lower".to_string(),
1791            mapping,
1792        };
1793        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1794
1795        let det = &rule.detection.named["selection"];
1796        if let Detection::AllOf(items) = det {
1797            // CommandLine → override from mapping
1798            assert_eq!(items[0].field.name, Some("cmd_line".to_string()));
1799            // ParentImage → lowercase (no override)
1800            assert_eq!(items[1].field.name, Some("parentimage".to_string()));
1801        } else {
1802            panic!("Expected AllOf");
1803        }
1804    }
1805
1806    #[test]
1807    fn test_field_name_transform_snake_case() {
1808        let mut rule = make_test_rule();
1809        let mut state = PipelineState::default();
1810        let t = Transformation::FieldNameTransform {
1811            transform_func: "snake_case".to_string(),
1812            mapping: HashMap::new(),
1813        };
1814        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1815
1816        let det = &rule.detection.named["selection"];
1817        if let Detection::AllOf(items) = det {
1818            assert_eq!(items[0].field.name, Some("command_line".to_string()));
1819            assert_eq!(items[1].field.name, Some("parent_image".to_string()));
1820        } else {
1821            panic!("Expected AllOf");
1822        }
1823    }
1824
1825    #[test]
1826    fn test_hashes_fields_decomposition() {
1827        let mut named = HashMap::new();
1828        named.insert(
1829            "selection".to_string(),
1830            Detection::AllOf(vec![DetectionItem {
1831                field: FieldSpec::new(Some("Hashes".to_string()), vec![]),
1832                values: vec![SigmaValue::String(SigmaString::new(
1833                    "SHA1=abc123,MD5=def456",
1834                ))],
1835            }]),
1836        );
1837
1838        let mut rule = make_test_rule();
1839        rule.detection.named = named;
1840
1841        let mut state = PipelineState::default();
1842        let t = Transformation::HashesFields {
1843            valid_hash_algos: vec!["SHA1".to_string(), "MD5".to_string()],
1844            field_prefix: "File".to_string(),
1845            drop_algo_prefix: false,
1846        };
1847        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1848
1849        let det = &rule.detection.named["selection"];
1850        if let Detection::AllOf(items) = det {
1851            assert_eq!(items.len(), 2);
1852            assert_eq!(items[0].field.name, Some("FileSHA1".to_string()));
1853            assert_eq!(items[1].field.name, Some("FileMD5".to_string()));
1854            if let SigmaValue::String(s) = &items[0].values[0] {
1855                assert_eq!(s.original, "abc123");
1856            }
1857            if let SigmaValue::String(s) = &items[1].values[0] {
1858                assert_eq!(s.original, "def456");
1859            }
1860        } else {
1861            panic!("Expected AllOf");
1862        }
1863    }
1864
1865    #[test]
1866    fn test_map_string() {
1867        let mut rule = make_test_rule();
1868        let mut state = PipelineState::default();
1869        let mut mapping = HashMap::new();
1870        mapping.insert("whoami".to_string(), vec!["who_am_i".to_string()]);
1871        let t = Transformation::MapString { mapping };
1872        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1873
1874        let det = &rule.detection.named["selection"];
1875        if let Detection::AllOf(items) = det {
1876            if let SigmaValue::String(s) = &items[0].values[0] {
1877                assert_eq!(s.original, "who_am_i");
1878            } else {
1879                panic!("Expected String value");
1880            }
1881        } else {
1882            panic!("Expected AllOf");
1883        }
1884    }
1885
1886    #[test]
1887    fn test_map_string_no_match() {
1888        let mut rule = make_test_rule();
1889        let mut state = PipelineState::default();
1890        let mut mapping = HashMap::new();
1891        mapping.insert("nonexistent".to_string(), vec!["replaced".to_string()]);
1892        let t = Transformation::MapString { mapping };
1893        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1894
1895        // Values should be unchanged
1896        let det = &rule.detection.named["selection"];
1897        if let Detection::AllOf(items) = det
1898            && let SigmaValue::String(s) = &items[0].values[0]
1899        {
1900            assert_eq!(s.original, "whoami");
1901        }
1902    }
1903
1904    #[test]
1905    fn test_set_value() {
1906        let mut rule = make_test_rule();
1907        let mut state = PipelineState::default();
1908        let t = Transformation::SetValue {
1909            value: SigmaValue::String(SigmaString::new("FIXED")),
1910        };
1911        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1912
1913        let det = &rule.detection.named["selection"];
1914        if let Detection::AllOf(items) = det {
1915            for item in items {
1916                assert_eq!(item.values.len(), 1);
1917                if let SigmaValue::String(s) = &item.values[0] {
1918                    assert_eq!(s.original, "FIXED");
1919                }
1920            }
1921        } else {
1922            panic!("Expected AllOf");
1923        }
1924    }
1925
1926    #[test]
1927    fn test_convert_type_string_to_int() {
1928        let mut named = HashMap::new();
1929        named.insert(
1930            "selection".to_string(),
1931            Detection::AllOf(vec![DetectionItem {
1932                field: FieldSpec::new(Some("EventID".to_string()), vec![]),
1933                values: vec![SigmaValue::String(SigmaString::new("4688"))],
1934            }]),
1935        );
1936        let mut rule = make_test_rule();
1937        rule.detection.named = named;
1938
1939        let mut state = PipelineState::default();
1940        let t = Transformation::ConvertType {
1941            target_type: "int".to_string(),
1942        };
1943        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1944
1945        let det = &rule.detection.named["selection"];
1946        if let Detection::AllOf(items) = det {
1947            assert!(matches!(items[0].values[0], SigmaValue::Integer(4688)));
1948        } else {
1949            panic!("Expected AllOf");
1950        }
1951    }
1952
1953    #[test]
1954    fn test_convert_type_int_to_string() {
1955        let mut named = HashMap::new();
1956        named.insert(
1957            "selection".to_string(),
1958            Detection::AllOf(vec![DetectionItem {
1959                field: FieldSpec::new(Some("EventID".to_string()), vec![]),
1960                values: vec![SigmaValue::Integer(4688)],
1961            }]),
1962        );
1963        let mut rule = make_test_rule();
1964        rule.detection.named = named;
1965
1966        let mut state = PipelineState::default();
1967        let t = Transformation::ConvertType {
1968            target_type: "str".to_string(),
1969        };
1970        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
1971
1972        let det = &rule.detection.named["selection"];
1973        if let Detection::AllOf(items) = det {
1974            if let SigmaValue::String(s) = &items[0].values[0] {
1975                assert_eq!(s.original, "4688");
1976            } else {
1977                panic!("Expected String");
1978            }
1979        }
1980    }
1981
1982    #[test]
1983    fn test_convert_type_to_bool() {
1984        let mut named = HashMap::new();
1985        named.insert(
1986            "selection".to_string(),
1987            Detection::AllOf(vec![DetectionItem {
1988                field: FieldSpec::new(Some("Enabled".to_string()), vec![]),
1989                values: vec![SigmaValue::String(SigmaString::new("true"))],
1990            }]),
1991        );
1992        let mut rule = make_test_rule();
1993        rule.detection.named = named;
1994
1995        let mut state = PipelineState::default();
1996        let t = Transformation::ConvertType {
1997            target_type: "bool".to_string(),
1998        };
1999        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2000
2001        let det = &rule.detection.named["selection"];
2002        if let Detection::AllOf(items) = det {
2003            assert!(matches!(items[0].values[0], SigmaValue::Bool(true)));
2004        }
2005    }
2006
2007    #[test]
2008    fn test_regex_noop() {
2009        let mut rule = make_test_rule();
2010        let mut state = PipelineState::default();
2011        let t = Transformation::Regex;
2012        let result = t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2013        assert!(!result); // no-op returns false
2014    }
2015
2016    #[test]
2017    fn test_add_field() {
2018        let mut rule = make_test_rule();
2019        assert!(rule.fields.is_empty());
2020
2021        let mut state = PipelineState::default();
2022        let t = Transformation::AddField {
2023            field: "EventID".to_string(),
2024        };
2025        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2026        assert_eq!(rule.fields, vec!["EventID".to_string()]);
2027
2028        // Adding again should not duplicate
2029        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2030        assert_eq!(rule.fields, vec!["EventID".to_string()]);
2031    }
2032
2033    #[test]
2034    fn test_remove_field() {
2035        let mut rule = make_test_rule();
2036        rule.fields = vec!["EventID".to_string(), "CommandLine".to_string()];
2037
2038        let mut state = PipelineState::default();
2039        let t = Transformation::RemoveField {
2040            field: "EventID".to_string(),
2041        };
2042        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2043        assert_eq!(rule.fields, vec!["CommandLine".to_string()]);
2044    }
2045
2046    #[test]
2047    fn test_set_field() {
2048        let mut rule = make_test_rule();
2049        rule.fields = vec!["old".to_string()];
2050
2051        let mut state = PipelineState::default();
2052        let t = Transformation::SetField {
2053            fields: vec!["new1".to_string(), "new2".to_string()],
2054        };
2055        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2056        assert_eq!(rule.fields, vec!["new1".to_string(), "new2".to_string()]);
2057    }
2058
2059    #[test]
2060    fn test_set_custom_attribute() {
2061        let mut rule = make_test_rule();
2062        let mut state = PipelineState::default();
2063        let t = Transformation::SetCustomAttribute {
2064            attribute: "custom.key".to_string(),
2065            value: "custom_value".to_string(),
2066        };
2067        let result = t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2068        assert!(result);
2069        assert_eq!(
2070            rule.custom_attributes.get("custom.key"),
2071            Some(&"custom_value".to_string())
2072        );
2073    }
2074
2075    #[test]
2076    fn test_case_transformation_lower() {
2077        let mut rule = make_test_rule();
2078        let mut state = PipelineState::default();
2079        let t = Transformation::CaseTransformation {
2080            case_type: "lower".to_string(),
2081        };
2082        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2083
2084        let det = &rule.detection.named["selection"];
2085        if let Detection::AllOf(items) = det {
2086            // "whoami" is already lowercase
2087            if let SigmaValue::String(s) = &items[0].values[0] {
2088                assert_eq!(s.original, "whoami");
2089            }
2090            // "\\cmd.exe" stays the same
2091            if let SigmaValue::String(s) = &items[1].values[0] {
2092                assert_eq!(s.original, "\\cmd.exe");
2093            }
2094        }
2095    }
2096
2097    #[test]
2098    fn test_case_transformation_upper() {
2099        let mut rule = make_test_rule();
2100        let mut state = PipelineState::default();
2101        let t = Transformation::CaseTransformation {
2102            case_type: "upper".to_string(),
2103        };
2104        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2105
2106        let det = &rule.detection.named["selection"];
2107        if let Detection::AllOf(items) = det {
2108            if let SigmaValue::String(s) = &items[0].values[0] {
2109                assert_eq!(s.original, "WHOAMI");
2110            }
2111            if let SigmaValue::String(s) = &items[1].values[0] {
2112                assert_eq!(s.original, "\\CMD.EXE");
2113            }
2114        }
2115    }
2116
2117    #[test]
2118    fn test_nest_transformation() {
2119        let mut rule = make_test_rule();
2120        let mut state = PipelineState::default();
2121
2122        // Create a nested pipeline: prefix + suffix in one nest
2123        let items = vec![
2124            super::super::TransformationItem {
2125                id: Some("inner_prefix".to_string()),
2126                transformation: Transformation::FieldNamePrefix {
2127                    prefix: "winlog.".to_string(),
2128                },
2129                rule_conditions: vec![],
2130                rule_cond_expr: None,
2131                detection_item_conditions: vec![],
2132                field_name_conditions: vec![],
2133                field_name_cond_not: false,
2134            },
2135            super::super::TransformationItem {
2136                id: Some("inner_suffix".to_string()),
2137                transformation: Transformation::FieldNameSuffix {
2138                    suffix: ".keyword".to_string(),
2139                },
2140                rule_conditions: vec![],
2141                rule_cond_expr: None,
2142                detection_item_conditions: vec![],
2143                field_name_conditions: vec![],
2144                field_name_cond_not: false,
2145            },
2146        ];
2147
2148        let t = Transformation::Nest { items };
2149        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2150
2151        let det = &rule.detection.named["selection"];
2152        if let Detection::AllOf(items) = det {
2153            assert_eq!(
2154                items[0].field.name,
2155                Some("winlog.CommandLine.keyword".to_string())
2156            );
2157            assert_eq!(
2158                items[1].field.name,
2159                Some("winlog.ParentImage.keyword".to_string())
2160            );
2161        } else {
2162            panic!("Expected AllOf");
2163        }
2164
2165        // Check inner items were tracked
2166        assert!(state.was_applied("inner_prefix"));
2167        assert!(state.was_applied("inner_suffix"));
2168    }
2169
2170    // =========================================================================
2171    // Untested transformation types
2172    // =========================================================================
2173
2174    #[test]
2175    fn test_field_name_prefix_mapping() {
2176        let mut rule = make_test_rule();
2177        let mut state = PipelineState::default();
2178        let mut mapping = HashMap::new();
2179        mapping.insert("Command".to_string(), "process.".to_string());
2180        mapping.insert("Parent".to_string(), "process.parent.".to_string());
2181
2182        let t = Transformation::FieldNamePrefixMapping { mapping };
2183        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2184
2185        let det = &rule.detection.named["selection"];
2186        if let Detection::AllOf(items) = det {
2187            // "CommandLine" starts with "Command" → "process." + "Line"
2188            assert_eq!(items[0].field.name, Some("process.Line".to_string()));
2189            // "ParentImage" starts with "Parent" → "process.parent." + "Image"
2190            assert_eq!(
2191                items[1].field.name,
2192                Some("process.parent.Image".to_string())
2193            );
2194        } else {
2195            panic!("Expected AllOf");
2196        }
2197    }
2198
2199    #[test]
2200    fn test_field_name_prefix_mapping_no_match() {
2201        let mut rule = make_test_rule();
2202        let mut state = PipelineState::default();
2203        let mut mapping = HashMap::new();
2204        mapping.insert("NoMatch".to_string(), "replaced.".to_string());
2205
2206        let t = Transformation::FieldNamePrefixMapping { mapping };
2207        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2208
2209        // Fields should be unchanged — no prefix matched
2210        let det = &rule.detection.named["selection"];
2211        if let Detection::AllOf(items) = det {
2212            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2213            assert_eq!(items[1].field.name, Some("ParentImage".to_string()));
2214        } else {
2215            panic!("Expected AllOf");
2216        }
2217    }
2218
2219    #[test]
2220    fn test_wildcard_placeholders_replaces_unresolved() {
2221        let mut named = HashMap::new();
2222        named.insert(
2223            "selection".to_string(),
2224            Detection::AllOf(vec![DetectionItem {
2225                field: FieldSpec::new(Some("User".to_string()), vec![]),
2226                values: vec![SigmaValue::String(SigmaString::new("%unknown_var%"))],
2227            }]),
2228        );
2229
2230        let mut rule = SigmaRule {
2231            title: "Test".to_string(),
2232            logsource: LogSource::default(),
2233            detection: Detections {
2234                named,
2235                conditions: vec![ConditionExpr::Identifier("selection".to_string())],
2236                condition_strings: vec!["selection".to_string()],
2237                timeframe: None,
2238            },
2239            id: None,
2240            name: None,
2241            related: vec![],
2242            taxonomy: None,
2243            status: None,
2244            description: None,
2245            license: None,
2246            author: None,
2247            references: vec![],
2248            date: None,
2249            modified: None,
2250            fields: vec![],
2251            falsepositives: vec![],
2252            level: None,
2253            tags: vec![],
2254            scope: vec![],
2255            custom_attributes: HashMap::new(),
2256        };
2257
2258        let mut state = PipelineState::default();
2259        // No vars set — placeholder should be replaced with wildcard
2260        let t = Transformation::WildcardPlaceholders;
2261        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2262
2263        let det = &rule.detection.named["selection"];
2264        if let Detection::AllOf(items) = det {
2265            if let SigmaValue::String(s) = &items[0].values[0] {
2266                assert_eq!(s.original, "*", "unresolved placeholder should become *");
2267            } else {
2268                panic!("Expected String value");
2269            }
2270        } else {
2271            panic!("Expected AllOf");
2272        }
2273    }
2274
2275    #[test]
2276    fn test_wildcard_placeholders_with_known_var() {
2277        let mut named = HashMap::new();
2278        named.insert(
2279            "selection".to_string(),
2280            Detection::AllOf(vec![DetectionItem {
2281                field: FieldSpec::new(Some("User".to_string()), vec![]),
2282                values: vec![SigmaValue::String(SigmaString::new("%admin%"))],
2283            }]),
2284        );
2285
2286        let mut rule = SigmaRule {
2287            title: "Test".to_string(),
2288            logsource: LogSource::default(),
2289            detection: Detections {
2290                named,
2291                conditions: vec![ConditionExpr::Identifier("selection".to_string())],
2292                condition_strings: vec!["selection".to_string()],
2293                timeframe: None,
2294            },
2295            id: None,
2296            name: None,
2297            related: vec![],
2298            taxonomy: None,
2299            status: None,
2300            description: None,
2301            license: None,
2302            author: None,
2303            references: vec![],
2304            date: None,
2305            modified: None,
2306            fields: vec![],
2307            falsepositives: vec![],
2308            level: None,
2309            tags: vec![],
2310            scope: vec![],
2311            custom_attributes: HashMap::new(),
2312        };
2313
2314        let mut state = PipelineState::default();
2315        state
2316            .vars
2317            .insert("admin".to_string(), vec!["root".to_string()]);
2318
2319        // WildcardPlaceholders should still expand known vars
2320        let t = Transformation::WildcardPlaceholders;
2321        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2322
2323        let det = &rule.detection.named["selection"];
2324        if let Detection::AllOf(items) = det {
2325            if let SigmaValue::String(s) = &items[0].values[0] {
2326                assert_eq!(s.original, "root");
2327            } else {
2328                panic!("Expected String value");
2329            }
2330        } else {
2331            panic!("Expected AllOf");
2332        }
2333    }
2334
2335    #[test]
2336    fn test_detection_item_failure_fires_on_match() {
2337        let mut rule = make_test_rule();
2338        let mut state = PipelineState::default();
2339
2340        // Condition that matches the "whoami" value in CommandLine
2341        let det_conds = vec![DetectionItemCondition::MatchString {
2342            regex: regex::Regex::new("whoami").unwrap(),
2343            negate: false,
2344        }];
2345
2346        let t = Transformation::DetectionItemFailure {
2347            message: "Unsupported detection item".to_string(),
2348        };
2349        let result = t.apply(&mut rule, &mut state, &det_conds, &[], false);
2350        assert!(result.is_err());
2351        let err = result.unwrap_err().to_string();
2352        assert!(err.contains("Unsupported detection item"));
2353    }
2354
2355    #[test]
2356    fn test_detection_item_failure_skips_on_no_match() {
2357        let mut rule = make_test_rule();
2358        let mut state = PipelineState::default();
2359
2360        // Condition that does NOT match any value
2361        let det_conds = vec![DetectionItemCondition::MatchString {
2362            regex: regex::Regex::new("nonexistent_value").unwrap(),
2363            negate: false,
2364        }];
2365
2366        let t = Transformation::DetectionItemFailure {
2367            message: "Should not fire".to_string(),
2368        };
2369        let result = t.apply(&mut rule, &mut state, &det_conds, &[], false);
2370        assert!(result.is_ok());
2371        assert!(!result.unwrap()); // returns false (not applied)
2372    }
2373
2374    #[test]
2375    fn test_query_expression_placeholders_noop() {
2376        let mut rule = make_test_rule();
2377        let mut state = PipelineState::default();
2378        let t = Transformation::QueryExpressionPlaceholders {
2379            expression: "{field}={value}".to_string(),
2380        };
2381        let result = t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2382        assert!(!result); // no-op returns false
2383    }
2384
2385    // =========================================================================
2386    // Edge cases: add_condition negated
2387    // =========================================================================
2388
2389    #[test]
2390    fn test_add_condition_negated() {
2391        let mut rule = make_test_rule();
2392        let mut state = PipelineState::default();
2393        let mut conds = HashMap::new();
2394        conds.insert(
2395            "User".to_string(),
2396            SigmaValue::String(SigmaString::new("SYSTEM")),
2397        );
2398        let t = Transformation::AddCondition {
2399            conditions: conds,
2400            negated: true,
2401        };
2402        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2403
2404        // The condition should be AND NOT (negated)
2405        assert_eq!(rule.detection.conditions.len(), 1);
2406        if let ConditionExpr::And(parts) = &rule.detection.conditions[0] {
2407            assert_eq!(parts.len(), 2);
2408            // Second part should be Not(...)
2409            assert!(
2410                matches!(&parts[1], ConditionExpr::Not(_)),
2411                "Expected negated condition, got: {:?}",
2412                parts[1]
2413            );
2414        } else {
2415            panic!("Expected And condition");
2416        }
2417    }
2418
2419    // =========================================================================
2420    // Edge cases: detection_item_conditions with transformations
2421    // =========================================================================
2422
2423    #[test]
2424    fn test_replace_string_with_detection_item_condition() {
2425        let mut rule = make_test_rule();
2426        let mut state = PipelineState::default();
2427
2428        // Only replace in items where value matches "whoami"
2429        let det_conds = vec![DetectionItemCondition::MatchString {
2430            regex: regex::Regex::new("whoami").unwrap(),
2431            negate: false,
2432        }];
2433
2434        let t = Transformation::ReplaceString {
2435            regex: r"whoami".to_string(),
2436            replacement: "REPLACED".to_string(),
2437            skip_special: false,
2438        };
2439        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2440            .unwrap();
2441
2442        let det = &rule.detection.named["selection"];
2443        if let Detection::AllOf(items) = det {
2444            // CommandLine value matches → replaced
2445            if let SigmaValue::String(s) = &items[0].values[0] {
2446                assert_eq!(s.original, "REPLACED");
2447            }
2448            // ParentImage value "\\cmd.exe" does NOT match → unchanged
2449            if let SigmaValue::String(s) = &items[1].values[0] {
2450                assert_eq!(s.original, "\\cmd.exe");
2451            }
2452        } else {
2453            panic!("Expected AllOf");
2454        }
2455    }
2456
2457    #[test]
2458    fn test_set_value_with_is_null_condition() {
2459        // Create a rule with a null value
2460        let mut named = HashMap::new();
2461        named.insert(
2462            "selection".to_string(),
2463            Detection::AllOf(vec![
2464                DetectionItem {
2465                    field: FieldSpec::new(Some("FieldA".to_string()), vec![]),
2466                    values: vec![SigmaValue::Null],
2467                },
2468                DetectionItem {
2469                    field: FieldSpec::new(Some("FieldB".to_string()), vec![]),
2470                    values: vec![SigmaValue::String(SigmaString::new("value"))],
2471                },
2472            ]),
2473        );
2474
2475        let mut rule = make_test_rule();
2476        rule.detection.named = named;
2477        let mut state = PipelineState::default();
2478
2479        // Only apply set_value to items with null values
2480        let det_conds = vec![DetectionItemCondition::IsNull { negate: false }];
2481
2482        let t = Transformation::SetValue {
2483            value: SigmaValue::String(SigmaString::new("DEFAULT")),
2484        };
2485        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2486            .unwrap();
2487
2488        let det = &rule.detection.named["selection"];
2489        if let Detection::AllOf(items) = det {
2490            // FieldA had null → should be replaced
2491            if let SigmaValue::String(s) = &items[0].values[0] {
2492                assert_eq!(s.original, "DEFAULT");
2493            } else {
2494                panic!("Expected String after set_value on null");
2495            }
2496            // FieldB had "value" → should be unchanged
2497            if let SigmaValue::String(s) = &items[1].values[0] {
2498                assert_eq!(s.original, "value");
2499            }
2500        } else {
2501            panic!("Expected AllOf");
2502        }
2503    }
2504
2505    #[test]
2506    fn test_drop_detection_item_with_match_string_condition() {
2507        let mut rule = make_test_rule();
2508        let mut state = PipelineState::default();
2509
2510        // Drop items where values match "whoami"
2511        let det_conds = vec![DetectionItemCondition::MatchString {
2512            regex: regex::Regex::new("whoami").unwrap(),
2513            negate: false,
2514        }];
2515
2516        let t = Transformation::DropDetectionItem;
2517        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2518            .unwrap();
2519
2520        let det = &rule.detection.named["selection"];
2521        if let Detection::AllOf(items) = det {
2522            assert_eq!(items.len(), 1);
2523            // Only ParentImage should remain
2524            assert_eq!(items[0].field.name, Some("ParentImage".to_string()));
2525        } else {
2526            panic!("Expected AllOf");
2527        }
2528    }
2529
2530    // =========================================================================
2531    // Edge case: field_name_cond_not (negated field name conditions)
2532    // =========================================================================
2533
2534    #[test]
2535    fn test_field_name_mapping_with_cond_not() {
2536        let mut rule = make_test_rule();
2537        let mut state = PipelineState::default();
2538
2539        // IncludeFields for CommandLine, but negated → apply to everything EXCEPT CommandLine
2540        let field_conds = vec![FieldNameCondition::IncludeFields {
2541            matcher: super::super::conditions::FieldMatcher::Plain(vec!["CommandLine".to_string()]),
2542        }];
2543
2544        let mut mapping = HashMap::new();
2545        mapping.insert("CommandLine".to_string(), "cmd".to_string());
2546        mapping.insert("ParentImage".to_string(), "parent".to_string());
2547
2548        let t = Transformation::FieldNameMapping { mapping };
2549        // field_name_cond_not = true → negate the field condition
2550        t.apply(&mut rule, &mut state, &[], &field_conds, true)
2551            .unwrap();
2552
2553        let det = &rule.detection.named["selection"];
2554        if let Detection::AllOf(items) = det {
2555            // CommandLine should NOT be mapped (negated: included fields are excluded)
2556            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2557            // ParentImage SHOULD be mapped (not in include list, negated = applies)
2558            assert_eq!(items[1].field.name, Some("parent".to_string()));
2559        } else {
2560            panic!("Expected AllOf");
2561        }
2562    }
2563
2564    // =========================================================================
2565    // Edge cases: empty inputs
2566    // =========================================================================
2567
2568    #[test]
2569    fn test_field_name_mapping_empty() {
2570        let mut rule = make_test_rule();
2571        let mut state = PipelineState::default();
2572        let t = Transformation::FieldNameMapping {
2573            mapping: HashMap::new(),
2574        };
2575        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2576
2577        // Fields should be unchanged
2578        let det = &rule.detection.named["selection"];
2579        if let Detection::AllOf(items) = det {
2580            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2581            assert_eq!(items[1].field.name, Some("ParentImage".to_string()));
2582        } else {
2583            panic!("Expected AllOf");
2584        }
2585    }
2586
2587    #[test]
2588    fn test_field_name_prefix_mapping_empty() {
2589        let mut rule = make_test_rule();
2590        let mut state = PipelineState::default();
2591        let t = Transformation::FieldNamePrefixMapping {
2592            mapping: HashMap::new(),
2593        };
2594        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2595
2596        let det = &rule.detection.named["selection"];
2597        if let Detection::AllOf(items) = det {
2598            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2599        } else {
2600            panic!("Expected AllOf");
2601        }
2602    }
2603
2604    #[test]
2605    fn test_map_string_empty_mapping() {
2606        let mut rule = make_test_rule();
2607        let mut state = PipelineState::default();
2608        let t = Transformation::MapString {
2609            mapping: HashMap::new(),
2610        };
2611        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2612
2613        let det = &rule.detection.named["selection"];
2614        if let Detection::AllOf(items) = det
2615            && let SigmaValue::String(s) = &items[0].values[0]
2616        {
2617            assert_eq!(s.original, "whoami");
2618        }
2619    }
2620
2621    #[test]
2622    fn test_hashes_fields_empty_algos() {
2623        // When valid_hash_algos is empty, all algorithms should be accepted
2624        let mut named = HashMap::new();
2625        named.insert(
2626            "selection".to_string(),
2627            Detection::AllOf(vec![DetectionItem {
2628                field: FieldSpec::new(Some("Hashes".to_string()), vec![]),
2629                values: vec![SigmaValue::String(SigmaString::new(
2630                    "SHA256=abc123,IMPHASH=def456",
2631                ))],
2632            }]),
2633        );
2634
2635        let mut rule = make_test_rule();
2636        rule.detection.named = named;
2637
2638        let mut state = PipelineState::default();
2639        let t = Transformation::HashesFields {
2640            valid_hash_algos: vec![], // empty = accept all
2641            field_prefix: "File".to_string(),
2642            drop_algo_prefix: false,
2643        };
2644        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2645
2646        let det = &rule.detection.named["selection"];
2647        if let Detection::AllOf(items) = det {
2648            assert_eq!(items.len(), 2);
2649            assert_eq!(items[0].field.name, Some("FileSHA256".to_string()));
2650            assert_eq!(items[1].field.name, Some("FileIMPHASH".to_string()));
2651        } else {
2652            panic!("Expected AllOf");
2653        }
2654    }
2655
2656    #[test]
2657    fn test_hashes_fields_drop_algo_prefix() {
2658        let mut named = HashMap::new();
2659        named.insert(
2660            "selection".to_string(),
2661            Detection::AllOf(vec![DetectionItem {
2662                field: FieldSpec::new(Some("Hashes".to_string()), vec![]),
2663                values: vec![SigmaValue::String(SigmaString::new("MD5=abc123"))],
2664            }]),
2665        );
2666
2667        let mut rule = make_test_rule();
2668        rule.detection.named = named;
2669        let mut state = PipelineState::default();
2670
2671        let t = Transformation::HashesFields {
2672            valid_hash_algos: vec!["MD5".to_string()],
2673            field_prefix: "Hash".to_string(),
2674            drop_algo_prefix: true,
2675        };
2676        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2677
2678        let det = &rule.detection.named["selection"];
2679        if let Detection::AllOf(items) = det {
2680            assert_eq!(items.len(), 1);
2681            // drop_algo_prefix = true → field name is just the prefix
2682            assert_eq!(items[0].field.name, Some("Hash".to_string()));
2683        } else {
2684            panic!("Expected AllOf");
2685        }
2686    }
2687
2688    // =========================================================================
2689    // Edge case: invalid regex in replace_string
2690    // =========================================================================
2691
2692    #[test]
2693    fn test_replace_string_invalid_regex() {
2694        let mut rule = make_test_rule();
2695        let mut state = PipelineState::default();
2696        let t = Transformation::ReplaceString {
2697            regex: r"[invalid".to_string(), // unclosed bracket
2698            replacement: "x".to_string(),
2699            skip_special: false,
2700        };
2701        let result = t.apply(&mut rule, &mut state, &[], &[], false);
2702        assert!(result.is_err());
2703        let err = result.unwrap_err().to_string();
2704        assert!(
2705            err.contains("bad regex"),
2706            "error should mention regex: {err}"
2707        );
2708    }
2709
2710    // =========================================================================
2711    // Edge case: detection_item_conditions with negate
2712    // =========================================================================
2713
2714    #[test]
2715    fn test_case_transformation_with_negated_match_string() {
2716        let mut rule = make_test_rule();
2717        let mut state = PipelineState::default();
2718
2719        // Negate: transform items that do NOT match "whoami"
2720        let det_conds = vec![DetectionItemCondition::MatchString {
2721            regex: regex::Regex::new("whoami").unwrap(),
2722            negate: true,
2723        }];
2724
2725        let t = Transformation::CaseTransformation {
2726            case_type: "upper".to_string(),
2727        };
2728        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2729            .unwrap();
2730
2731        let det = &rule.detection.named["selection"];
2732        if let Detection::AllOf(items) = det {
2733            // CommandLine has "whoami" → negate means NOT matched → unchanged
2734            if let SigmaValue::String(s) = &items[0].values[0] {
2735                assert_eq!(s.original, "whoami");
2736            }
2737            // ParentImage has "\\cmd.exe" → negate means matched → uppercased
2738            if let SigmaValue::String(s) = &items[1].values[0] {
2739                assert_eq!(s.original, "\\CMD.EXE");
2740            }
2741        } else {
2742            panic!("Expected AllOf");
2743        }
2744    }
2745
2746    // =========================================================================
2747    // Integration: multi-transformation chaining pipeline (YAML)
2748    // =========================================================================
2749
2750    #[test]
2751    fn test_multi_transformation_chaining_pipeline() {
2752        use crate::pipeline::parse_pipeline;
2753
2754        let yaml = r#"
2755name: Multi-step Pipeline
2756transformations:
2757  - id: step1_map
2758    type: field_name_mapping
2759    mapping:
2760      CommandLine: process.command_line
2761      ParentImage: process.parent.executable
2762  - id: step2_prefix
2763    type: field_name_prefix
2764    prefix: "winlog."
2765    rule_conditions:
2766      - type: logsource
2767        product: windows
2768  - id: step3_case
2769    type: case_transformation
2770    case_type: upper
2771    field_name_conditions:
2772      - type: include_fields
2773        fields:
2774          - winlog.process.command_line
2775  - id: step4_attr
2776    type: set_custom_attribute
2777    attribute: rsigma.processed
2778    value: "true"
2779"#;
2780        let pipeline = parse_pipeline(yaml).unwrap();
2781
2782        let mut rule = make_test_rule(); // Windows process_creation rule
2783        let mut state = PipelineState::new(pipeline.vars.clone());
2784        pipeline.apply(&mut rule, &mut state).unwrap();
2785
2786        let det = &rule.detection.named["selection"];
2787        if let Detection::AllOf(items) = det {
2788            // step1: CommandLine → process.command_line
2789            // step2: process.command_line → winlog.process.command_line
2790            assert_eq!(
2791                items[0].field.name,
2792                Some("winlog.process.command_line".to_string())
2793            );
2794            // step3: case upper only on winlog.process.command_line
2795            if let SigmaValue::String(s) = &items[0].values[0] {
2796                assert_eq!(s.original, "WHOAMI");
2797            }
2798
2799            // ParentImage → process.parent.executable → winlog.process.parent.executable
2800            assert_eq!(
2801                items[1].field.name,
2802                Some("winlog.process.parent.executable".to_string())
2803            );
2804            // step3 does NOT apply to this field → value unchanged
2805            if let SigmaValue::String(s) = &items[1].values[0] {
2806                assert_eq!(s.original, "\\cmd.exe");
2807            }
2808        } else {
2809            panic!("Expected AllOf");
2810        }
2811
2812        // step4: custom attribute was set
2813        assert_eq!(
2814            rule.custom_attributes.get("rsigma.processed"),
2815            Some(&"true".to_string())
2816        );
2817
2818        // All steps should be tracked
2819        assert!(state.was_applied("step1_map"));
2820        assert!(state.was_applied("step2_prefix"));
2821        assert!(state.was_applied("step3_case"));
2822        assert!(state.was_applied("step4_attr"));
2823    }
2824
2825    // =========================================================================
2826    // MapString one-to-many tests
2827    // =========================================================================
2828
2829    #[test]
2830    fn test_map_string_one_to_many() {
2831        let mut rule = make_test_rule();
2832        let mut state = PipelineState::default();
2833        let mut mapping = HashMap::new();
2834        mapping.insert(
2835            "whoami".to_string(),
2836            vec!["who".to_string(), "am_i".to_string(), "test".to_string()],
2837        );
2838        let t = Transformation::MapString { mapping };
2839        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2840
2841        let det = &rule.detection.named["selection"];
2842        if let Detection::AllOf(items) = det {
2843            // Original single value should expand to 3 values
2844            assert_eq!(items[0].values.len(), 3);
2845            if let SigmaValue::String(s) = &items[0].values[0] {
2846                assert_eq!(s.original, "who");
2847            }
2848            if let SigmaValue::String(s) = &items[0].values[1] {
2849                assert_eq!(s.original, "am_i");
2850            }
2851            if let SigmaValue::String(s) = &items[0].values[2] {
2852                assert_eq!(s.original, "test");
2853            }
2854        } else {
2855            panic!("Expected AllOf");
2856        }
2857    }
2858
2859    #[test]
2860    fn test_map_string_one_to_many_mixed() {
2861        // Test that non-matching values remain and only matching ones expand
2862        let yaml = r#"
2863title: Test Rule
2864logsource:
2865    product: windows
2866detection:
2867    selection:
2868        CommandLine:
2869            - whoami
2870            - ipconfig
2871    condition: selection
2872level: medium
2873"#;
2874        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
2875        let mut rule = collection.rules[0].clone();
2876        let mut state = PipelineState::default();
2877        let mut mapping = HashMap::new();
2878        mapping.insert(
2879            "whoami".to_string(),
2880            vec!["who".to_string(), "am_i".to_string()],
2881        );
2882        // ipconfig is not in the mapping, should remain unchanged
2883        let t = Transformation::MapString { mapping };
2884        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2885
2886        let det = &rule.detection.named["selection"];
2887        if let Detection::AllOf(items) = det {
2888            // "whoami" expanded to 2 + "ipconfig" stays = 3 total
2889            assert_eq!(items[0].values.len(), 3);
2890            if let SigmaValue::String(s) = &items[0].values[0] {
2891                assert_eq!(s.original, "who");
2892            }
2893            if let SigmaValue::String(s) = &items[0].values[1] {
2894                assert_eq!(s.original, "am_i");
2895            }
2896            if let SigmaValue::String(s) = &items[0].values[2] {
2897                assert_eq!(s.original, "ipconfig");
2898            }
2899        } else {
2900            panic!("Expected AllOf");
2901        }
2902    }
2903
2904    // =========================================================================
2905    // ReplaceString skip_special tests
2906    // =========================================================================
2907
2908    #[test]
2909    fn test_replace_string_skip_special_preserves_wildcards() {
2910        // Value with wildcards written directly in YAML: "*whoami*"
2911        let yaml = r#"
2912title: Test Rule
2913logsource:
2914    product: windows
2915detection:
2916    selection:
2917        CommandLine: '*whoami*'
2918    condition: selection
2919level: medium
2920"#;
2921        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
2922        let mut rule = collection.rules[0].clone();
2923        let mut state = PipelineState::default();
2924        let t = Transformation::ReplaceString {
2925            regex: r"whoami".to_string(),
2926            replacement: "REPLACED".to_string(),
2927            skip_special: true,
2928        };
2929        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2930
2931        let det = &rule.detection.named["selection"];
2932        if let Detection::AllOf(items) = det {
2933            let s = match &items[0].values[0] {
2934                SigmaValue::String(s) => s,
2935                _ => panic!("Expected String"),
2936            };
2937            // Wildcards should be preserved, plain part replaced
2938            assert!(s.contains_wildcards(), "Wildcards should be preserved");
2939            assert!(
2940                s.original.contains("REPLACED"),
2941                "Plain part should be replaced, got: {}",
2942                s.original
2943            );
2944            assert!(
2945                !s.original.contains("whoami"),
2946                "Original text should be gone"
2947            );
2948        } else {
2949            panic!("Expected AllOf");
2950        }
2951    }
2952
2953    #[test]
2954    fn test_replace_string_skip_special_false_replaces_whole() {
2955        // Without skip_special, the entire original is replaced (wildcards treated as text)
2956        let yaml = r#"
2957title: Test Rule
2958logsource:
2959    product: windows
2960detection:
2961    selection:
2962        CommandLine: '*whoami*'
2963    condition: selection
2964level: medium
2965"#;
2966        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
2967        let mut rule = collection.rules[0].clone();
2968        let mut state = PipelineState::default();
2969        let t = Transformation::ReplaceString {
2970            regex: r"\*".to_string(),
2971            replacement: "STAR".to_string(),
2972            skip_special: false,
2973        };
2974        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2975
2976        let det = &rule.detection.named["selection"];
2977        if let Detection::AllOf(items) = det {
2978            let s = match &items[0].values[0] {
2979                SigmaValue::String(s) => s,
2980                _ => panic!("Expected String"),
2981            };
2982            // skip_special=false replaces on the original string (which has literal * chars)
2983            assert!(
2984                s.original.contains("STAR"),
2985                "Wildcards in original should be replaced as text, got: {}",
2986                s.original
2987            );
2988        } else {
2989            panic!("Expected AllOf");
2990        }
2991    }
2992
2993    #[test]
2994    fn test_replace_string_skip_special_plain_string() {
2995        // Plain string (no wildcards) with skip_special=true → should still replace
2996        let mut rule = make_test_rule();
2997        let mut state = PipelineState::default();
2998        let t = Transformation::ReplaceString {
2999            regex: r"whoami".to_string(),
3000            replacement: "REPLACED".to_string(),
3001            skip_special: true,
3002        };
3003        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
3004
3005        let det = &rule.detection.named["selection"];
3006        if let Detection::AllOf(items) = det
3007            && let SigmaValue::String(s) = &items[0].values[0]
3008        {
3009            assert_eq!(s.original, "REPLACED");
3010        }
3011    }
3012
3013    // =========================================================================
3014    // CaseTransformation snake_case tests
3015    // =========================================================================
3016
3017    #[test]
3018    fn test_case_transformation_snake_case() {
3019        let yaml = r#"
3020title: Test Rule
3021logsource:
3022    product: windows
3023detection:
3024    selection:
3025        CommandLine: CommandAndControl
3026    condition: selection
3027level: medium
3028"#;
3029        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
3030        let mut rule = collection.rules[0].clone();
3031        let mut state = PipelineState::default();
3032        let t = Transformation::CaseTransformation {
3033            case_type: "snake_case".to_string(),
3034        };
3035        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
3036
3037        let det = &rule.detection.named["selection"];
3038        if let Detection::AllOf(items) = det {
3039            if let SigmaValue::String(s) = &items[0].values[0] {
3040                assert_eq!(s.original, "command_and_control");
3041            } else {
3042                panic!("Expected String");
3043            }
3044        } else {
3045            panic!("Expected AllOf");
3046        }
3047    }
3048
3049    #[test]
3050    fn test_case_transformation_snake_case_already_lowercase() {
3051        let mut rule = make_test_rule(); // "whoami" is already lowercase
3052        let mut state = PipelineState::default();
3053        let t = Transformation::CaseTransformation {
3054            case_type: "snake_case".to_string(),
3055        };
3056        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
3057
3058        let det = &rule.detection.named["selection"];
3059        if let Detection::AllOf(items) = det
3060            && let SigmaValue::String(s) = &items[0].values[0]
3061        {
3062            assert_eq!(s.original, "whoami"); // unchanged
3063        }
3064    }
3065}