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` as a
137    /// `serde_yaml::Value::String`. Backends / engines can read these to
138    /// modify per-rule behavior (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(), serde_yaml::Value::String(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
2071                .get("custom.key")
2072                .and_then(|v| v.as_str()),
2073            Some("custom_value")
2074        );
2075    }
2076
2077    #[test]
2078    fn test_case_transformation_lower() {
2079        let mut rule = make_test_rule();
2080        let mut state = PipelineState::default();
2081        let t = Transformation::CaseTransformation {
2082            case_type: "lower".to_string(),
2083        };
2084        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2085
2086        let det = &rule.detection.named["selection"];
2087        if let Detection::AllOf(items) = det {
2088            // "whoami" is already lowercase
2089            if let SigmaValue::String(s) = &items[0].values[0] {
2090                assert_eq!(s.original, "whoami");
2091            }
2092            // "\\cmd.exe" stays the same
2093            if let SigmaValue::String(s) = &items[1].values[0] {
2094                assert_eq!(s.original, "\\cmd.exe");
2095            }
2096        }
2097    }
2098
2099    #[test]
2100    fn test_case_transformation_upper() {
2101        let mut rule = make_test_rule();
2102        let mut state = PipelineState::default();
2103        let t = Transformation::CaseTransformation {
2104            case_type: "upper".to_string(),
2105        };
2106        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2107
2108        let det = &rule.detection.named["selection"];
2109        if let Detection::AllOf(items) = det {
2110            if let SigmaValue::String(s) = &items[0].values[0] {
2111                assert_eq!(s.original, "WHOAMI");
2112            }
2113            if let SigmaValue::String(s) = &items[1].values[0] {
2114                assert_eq!(s.original, "\\CMD.EXE");
2115            }
2116        }
2117    }
2118
2119    #[test]
2120    fn test_nest_transformation() {
2121        let mut rule = make_test_rule();
2122        let mut state = PipelineState::default();
2123
2124        // Create a nested pipeline: prefix + suffix in one nest
2125        let items = vec![
2126            super::super::TransformationItem {
2127                id: Some("inner_prefix".to_string()),
2128                transformation: Transformation::FieldNamePrefix {
2129                    prefix: "winlog.".to_string(),
2130                },
2131                rule_conditions: vec![],
2132                rule_cond_expr: None,
2133                detection_item_conditions: vec![],
2134                field_name_conditions: vec![],
2135                field_name_cond_not: false,
2136            },
2137            super::super::TransformationItem {
2138                id: Some("inner_suffix".to_string()),
2139                transformation: Transformation::FieldNameSuffix {
2140                    suffix: ".keyword".to_string(),
2141                },
2142                rule_conditions: vec![],
2143                rule_cond_expr: None,
2144                detection_item_conditions: vec![],
2145                field_name_conditions: vec![],
2146                field_name_cond_not: false,
2147            },
2148        ];
2149
2150        let t = Transformation::Nest { items };
2151        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2152
2153        let det = &rule.detection.named["selection"];
2154        if let Detection::AllOf(items) = det {
2155            assert_eq!(
2156                items[0].field.name,
2157                Some("winlog.CommandLine.keyword".to_string())
2158            );
2159            assert_eq!(
2160                items[1].field.name,
2161                Some("winlog.ParentImage.keyword".to_string())
2162            );
2163        } else {
2164            panic!("Expected AllOf");
2165        }
2166
2167        // Check inner items were tracked
2168        assert!(state.was_applied("inner_prefix"));
2169        assert!(state.was_applied("inner_suffix"));
2170    }
2171
2172    // =========================================================================
2173    // Untested transformation types
2174    // =========================================================================
2175
2176    #[test]
2177    fn test_field_name_prefix_mapping() {
2178        let mut rule = make_test_rule();
2179        let mut state = PipelineState::default();
2180        let mut mapping = HashMap::new();
2181        mapping.insert("Command".to_string(), "process.".to_string());
2182        mapping.insert("Parent".to_string(), "process.parent.".to_string());
2183
2184        let t = Transformation::FieldNamePrefixMapping { mapping };
2185        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2186
2187        let det = &rule.detection.named["selection"];
2188        if let Detection::AllOf(items) = det {
2189            // "CommandLine" starts with "Command" → "process." + "Line"
2190            assert_eq!(items[0].field.name, Some("process.Line".to_string()));
2191            // "ParentImage" starts with "Parent" → "process.parent." + "Image"
2192            assert_eq!(
2193                items[1].field.name,
2194                Some("process.parent.Image".to_string())
2195            );
2196        } else {
2197            panic!("Expected AllOf");
2198        }
2199    }
2200
2201    #[test]
2202    fn test_field_name_prefix_mapping_no_match() {
2203        let mut rule = make_test_rule();
2204        let mut state = PipelineState::default();
2205        let mut mapping = HashMap::new();
2206        mapping.insert("NoMatch".to_string(), "replaced.".to_string());
2207
2208        let t = Transformation::FieldNamePrefixMapping { mapping };
2209        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2210
2211        // Fields should be unchanged — no prefix matched
2212        let det = &rule.detection.named["selection"];
2213        if let Detection::AllOf(items) = det {
2214            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2215            assert_eq!(items[1].field.name, Some("ParentImage".to_string()));
2216        } else {
2217            panic!("Expected AllOf");
2218        }
2219    }
2220
2221    #[test]
2222    fn test_wildcard_placeholders_replaces_unresolved() {
2223        let mut named = HashMap::new();
2224        named.insert(
2225            "selection".to_string(),
2226            Detection::AllOf(vec![DetectionItem {
2227                field: FieldSpec::new(Some("User".to_string()), vec![]),
2228                values: vec![SigmaValue::String(SigmaString::new("%unknown_var%"))],
2229            }]),
2230        );
2231
2232        let mut rule = SigmaRule {
2233            title: "Test".to_string(),
2234            logsource: LogSource::default(),
2235            detection: Detections {
2236                named,
2237                conditions: vec![ConditionExpr::Identifier("selection".to_string())],
2238                condition_strings: vec!["selection".to_string()],
2239                timeframe: None,
2240            },
2241            id: None,
2242            name: None,
2243            related: vec![],
2244            taxonomy: None,
2245            status: None,
2246            description: None,
2247            license: None,
2248            author: None,
2249            references: vec![],
2250            date: None,
2251            modified: None,
2252            fields: vec![],
2253            falsepositives: vec![],
2254            level: None,
2255            tags: vec![],
2256            scope: vec![],
2257            custom_attributes: HashMap::new(),
2258        };
2259
2260        let mut state = PipelineState::default();
2261        // No vars set — placeholder should be replaced with wildcard
2262        let t = Transformation::WildcardPlaceholders;
2263        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2264
2265        let det = &rule.detection.named["selection"];
2266        if let Detection::AllOf(items) = det {
2267            if let SigmaValue::String(s) = &items[0].values[0] {
2268                assert_eq!(s.original, "*", "unresolved placeholder should become *");
2269            } else {
2270                panic!("Expected String value");
2271            }
2272        } else {
2273            panic!("Expected AllOf");
2274        }
2275    }
2276
2277    #[test]
2278    fn test_wildcard_placeholders_with_known_var() {
2279        let mut named = HashMap::new();
2280        named.insert(
2281            "selection".to_string(),
2282            Detection::AllOf(vec![DetectionItem {
2283                field: FieldSpec::new(Some("User".to_string()), vec![]),
2284                values: vec![SigmaValue::String(SigmaString::new("%admin%"))],
2285            }]),
2286        );
2287
2288        let mut rule = SigmaRule {
2289            title: "Test".to_string(),
2290            logsource: LogSource::default(),
2291            detection: Detections {
2292                named,
2293                conditions: vec![ConditionExpr::Identifier("selection".to_string())],
2294                condition_strings: vec!["selection".to_string()],
2295                timeframe: None,
2296            },
2297            id: None,
2298            name: None,
2299            related: vec![],
2300            taxonomy: None,
2301            status: None,
2302            description: None,
2303            license: None,
2304            author: None,
2305            references: vec![],
2306            date: None,
2307            modified: None,
2308            fields: vec![],
2309            falsepositives: vec![],
2310            level: None,
2311            tags: vec![],
2312            scope: vec![],
2313            custom_attributes: HashMap::new(),
2314        };
2315
2316        let mut state = PipelineState::default();
2317        state
2318            .vars
2319            .insert("admin".to_string(), vec!["root".to_string()]);
2320
2321        // WildcardPlaceholders should still expand known vars
2322        let t = Transformation::WildcardPlaceholders;
2323        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2324
2325        let det = &rule.detection.named["selection"];
2326        if let Detection::AllOf(items) = det {
2327            if let SigmaValue::String(s) = &items[0].values[0] {
2328                assert_eq!(s.original, "root");
2329            } else {
2330                panic!("Expected String value");
2331            }
2332        } else {
2333            panic!("Expected AllOf");
2334        }
2335    }
2336
2337    #[test]
2338    fn test_detection_item_failure_fires_on_match() {
2339        let mut rule = make_test_rule();
2340        let mut state = PipelineState::default();
2341
2342        // Condition that matches the "whoami" value in CommandLine
2343        let det_conds = vec![DetectionItemCondition::MatchString {
2344            regex: regex::Regex::new("whoami").unwrap(),
2345            negate: false,
2346        }];
2347
2348        let t = Transformation::DetectionItemFailure {
2349            message: "Unsupported detection item".to_string(),
2350        };
2351        let result = t.apply(&mut rule, &mut state, &det_conds, &[], false);
2352        assert!(result.is_err());
2353        let err = result.unwrap_err().to_string();
2354        assert!(err.contains("Unsupported detection item"));
2355    }
2356
2357    #[test]
2358    fn test_detection_item_failure_skips_on_no_match() {
2359        let mut rule = make_test_rule();
2360        let mut state = PipelineState::default();
2361
2362        // Condition that does NOT match any value
2363        let det_conds = vec![DetectionItemCondition::MatchString {
2364            regex: regex::Regex::new("nonexistent_value").unwrap(),
2365            negate: false,
2366        }];
2367
2368        let t = Transformation::DetectionItemFailure {
2369            message: "Should not fire".to_string(),
2370        };
2371        let result = t.apply(&mut rule, &mut state, &det_conds, &[], false);
2372        assert!(result.is_ok());
2373        assert!(!result.unwrap()); // returns false (not applied)
2374    }
2375
2376    #[test]
2377    fn test_query_expression_placeholders_noop() {
2378        let mut rule = make_test_rule();
2379        let mut state = PipelineState::default();
2380        let t = Transformation::QueryExpressionPlaceholders {
2381            expression: "{field}={value}".to_string(),
2382        };
2383        let result = t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2384        assert!(!result); // no-op returns false
2385    }
2386
2387    // =========================================================================
2388    // Edge cases: add_condition negated
2389    // =========================================================================
2390
2391    #[test]
2392    fn test_add_condition_negated() {
2393        let mut rule = make_test_rule();
2394        let mut state = PipelineState::default();
2395        let mut conds = HashMap::new();
2396        conds.insert(
2397            "User".to_string(),
2398            SigmaValue::String(SigmaString::new("SYSTEM")),
2399        );
2400        let t = Transformation::AddCondition {
2401            conditions: conds,
2402            negated: true,
2403        };
2404        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2405
2406        // The condition should be AND NOT (negated)
2407        assert_eq!(rule.detection.conditions.len(), 1);
2408        if let ConditionExpr::And(parts) = &rule.detection.conditions[0] {
2409            assert_eq!(parts.len(), 2);
2410            // Second part should be Not(...)
2411            assert!(
2412                matches!(&parts[1], ConditionExpr::Not(_)),
2413                "Expected negated condition, got: {:?}",
2414                parts[1]
2415            );
2416        } else {
2417            panic!("Expected And condition");
2418        }
2419    }
2420
2421    // =========================================================================
2422    // Edge cases: detection_item_conditions with transformations
2423    // =========================================================================
2424
2425    #[test]
2426    fn test_replace_string_with_detection_item_condition() {
2427        let mut rule = make_test_rule();
2428        let mut state = PipelineState::default();
2429
2430        // Only replace in items where value matches "whoami"
2431        let det_conds = vec![DetectionItemCondition::MatchString {
2432            regex: regex::Regex::new("whoami").unwrap(),
2433            negate: false,
2434        }];
2435
2436        let t = Transformation::ReplaceString {
2437            regex: r"whoami".to_string(),
2438            replacement: "REPLACED".to_string(),
2439            skip_special: false,
2440        };
2441        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2442            .unwrap();
2443
2444        let det = &rule.detection.named["selection"];
2445        if let Detection::AllOf(items) = det {
2446            // CommandLine value matches → replaced
2447            if let SigmaValue::String(s) = &items[0].values[0] {
2448                assert_eq!(s.original, "REPLACED");
2449            }
2450            // ParentImage value "\\cmd.exe" does NOT match → unchanged
2451            if let SigmaValue::String(s) = &items[1].values[0] {
2452                assert_eq!(s.original, "\\cmd.exe");
2453            }
2454        } else {
2455            panic!("Expected AllOf");
2456        }
2457    }
2458
2459    #[test]
2460    fn test_set_value_with_is_null_condition() {
2461        // Create a rule with a null value
2462        let mut named = HashMap::new();
2463        named.insert(
2464            "selection".to_string(),
2465            Detection::AllOf(vec![
2466                DetectionItem {
2467                    field: FieldSpec::new(Some("FieldA".to_string()), vec![]),
2468                    values: vec![SigmaValue::Null],
2469                },
2470                DetectionItem {
2471                    field: FieldSpec::new(Some("FieldB".to_string()), vec![]),
2472                    values: vec![SigmaValue::String(SigmaString::new("value"))],
2473                },
2474            ]),
2475        );
2476
2477        let mut rule = make_test_rule();
2478        rule.detection.named = named;
2479        let mut state = PipelineState::default();
2480
2481        // Only apply set_value to items with null values
2482        let det_conds = vec![DetectionItemCondition::IsNull { negate: false }];
2483
2484        let t = Transformation::SetValue {
2485            value: SigmaValue::String(SigmaString::new("DEFAULT")),
2486        };
2487        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2488            .unwrap();
2489
2490        let det = &rule.detection.named["selection"];
2491        if let Detection::AllOf(items) = det {
2492            // FieldA had null → should be replaced
2493            if let SigmaValue::String(s) = &items[0].values[0] {
2494                assert_eq!(s.original, "DEFAULT");
2495            } else {
2496                panic!("Expected String after set_value on null");
2497            }
2498            // FieldB had "value" → should be unchanged
2499            if let SigmaValue::String(s) = &items[1].values[0] {
2500                assert_eq!(s.original, "value");
2501            }
2502        } else {
2503            panic!("Expected AllOf");
2504        }
2505    }
2506
2507    #[test]
2508    fn test_drop_detection_item_with_match_string_condition() {
2509        let mut rule = make_test_rule();
2510        let mut state = PipelineState::default();
2511
2512        // Drop items where values match "whoami"
2513        let det_conds = vec![DetectionItemCondition::MatchString {
2514            regex: regex::Regex::new("whoami").unwrap(),
2515            negate: false,
2516        }];
2517
2518        let t = Transformation::DropDetectionItem;
2519        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2520            .unwrap();
2521
2522        let det = &rule.detection.named["selection"];
2523        if let Detection::AllOf(items) = det {
2524            assert_eq!(items.len(), 1);
2525            // Only ParentImage should remain
2526            assert_eq!(items[0].field.name, Some("ParentImage".to_string()));
2527        } else {
2528            panic!("Expected AllOf");
2529        }
2530    }
2531
2532    // =========================================================================
2533    // Edge case: field_name_cond_not (negated field name conditions)
2534    // =========================================================================
2535
2536    #[test]
2537    fn test_field_name_mapping_with_cond_not() {
2538        let mut rule = make_test_rule();
2539        let mut state = PipelineState::default();
2540
2541        // IncludeFields for CommandLine, but negated → apply to everything EXCEPT CommandLine
2542        let field_conds = vec![FieldNameCondition::IncludeFields {
2543            matcher: super::super::conditions::FieldMatcher::Plain(vec!["CommandLine".to_string()]),
2544        }];
2545
2546        let mut mapping = HashMap::new();
2547        mapping.insert("CommandLine".to_string(), "cmd".to_string());
2548        mapping.insert("ParentImage".to_string(), "parent".to_string());
2549
2550        let t = Transformation::FieldNameMapping { mapping };
2551        // field_name_cond_not = true → negate the field condition
2552        t.apply(&mut rule, &mut state, &[], &field_conds, true)
2553            .unwrap();
2554
2555        let det = &rule.detection.named["selection"];
2556        if let Detection::AllOf(items) = det {
2557            // CommandLine should NOT be mapped (negated: included fields are excluded)
2558            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2559            // ParentImage SHOULD be mapped (not in include list, negated = applies)
2560            assert_eq!(items[1].field.name, Some("parent".to_string()));
2561        } else {
2562            panic!("Expected AllOf");
2563        }
2564    }
2565
2566    // =========================================================================
2567    // Edge cases: empty inputs
2568    // =========================================================================
2569
2570    #[test]
2571    fn test_field_name_mapping_empty() {
2572        let mut rule = make_test_rule();
2573        let mut state = PipelineState::default();
2574        let t = Transformation::FieldNameMapping {
2575            mapping: HashMap::new(),
2576        };
2577        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2578
2579        // Fields should be unchanged
2580        let det = &rule.detection.named["selection"];
2581        if let Detection::AllOf(items) = det {
2582            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2583            assert_eq!(items[1].field.name, Some("ParentImage".to_string()));
2584        } else {
2585            panic!("Expected AllOf");
2586        }
2587    }
2588
2589    #[test]
2590    fn test_field_name_prefix_mapping_empty() {
2591        let mut rule = make_test_rule();
2592        let mut state = PipelineState::default();
2593        let t = Transformation::FieldNamePrefixMapping {
2594            mapping: HashMap::new(),
2595        };
2596        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2597
2598        let det = &rule.detection.named["selection"];
2599        if let Detection::AllOf(items) = det {
2600            assert_eq!(items[0].field.name, Some("CommandLine".to_string()));
2601        } else {
2602            panic!("Expected AllOf");
2603        }
2604    }
2605
2606    #[test]
2607    fn test_map_string_empty_mapping() {
2608        let mut rule = make_test_rule();
2609        let mut state = PipelineState::default();
2610        let t = Transformation::MapString {
2611            mapping: HashMap::new(),
2612        };
2613        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2614
2615        let det = &rule.detection.named["selection"];
2616        if let Detection::AllOf(items) = det
2617            && let SigmaValue::String(s) = &items[0].values[0]
2618        {
2619            assert_eq!(s.original, "whoami");
2620        }
2621    }
2622
2623    #[test]
2624    fn test_hashes_fields_empty_algos() {
2625        // When valid_hash_algos is empty, all algorithms should be accepted
2626        let mut named = HashMap::new();
2627        named.insert(
2628            "selection".to_string(),
2629            Detection::AllOf(vec![DetectionItem {
2630                field: FieldSpec::new(Some("Hashes".to_string()), vec![]),
2631                values: vec![SigmaValue::String(SigmaString::new(
2632                    "SHA256=abc123,IMPHASH=def456",
2633                ))],
2634            }]),
2635        );
2636
2637        let mut rule = make_test_rule();
2638        rule.detection.named = named;
2639
2640        let mut state = PipelineState::default();
2641        let t = Transformation::HashesFields {
2642            valid_hash_algos: vec![], // empty = accept all
2643            field_prefix: "File".to_string(),
2644            drop_algo_prefix: false,
2645        };
2646        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2647
2648        let det = &rule.detection.named["selection"];
2649        if let Detection::AllOf(items) = det {
2650            assert_eq!(items.len(), 2);
2651            assert_eq!(items[0].field.name, Some("FileSHA256".to_string()));
2652            assert_eq!(items[1].field.name, Some("FileIMPHASH".to_string()));
2653        } else {
2654            panic!("Expected AllOf");
2655        }
2656    }
2657
2658    #[test]
2659    fn test_hashes_fields_drop_algo_prefix() {
2660        let mut named = HashMap::new();
2661        named.insert(
2662            "selection".to_string(),
2663            Detection::AllOf(vec![DetectionItem {
2664                field: FieldSpec::new(Some("Hashes".to_string()), vec![]),
2665                values: vec![SigmaValue::String(SigmaString::new("MD5=abc123"))],
2666            }]),
2667        );
2668
2669        let mut rule = make_test_rule();
2670        rule.detection.named = named;
2671        let mut state = PipelineState::default();
2672
2673        let t = Transformation::HashesFields {
2674            valid_hash_algos: vec!["MD5".to_string()],
2675            field_prefix: "Hash".to_string(),
2676            drop_algo_prefix: true,
2677        };
2678        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2679
2680        let det = &rule.detection.named["selection"];
2681        if let Detection::AllOf(items) = det {
2682            assert_eq!(items.len(), 1);
2683            // drop_algo_prefix = true → field name is just the prefix
2684            assert_eq!(items[0].field.name, Some("Hash".to_string()));
2685        } else {
2686            panic!("Expected AllOf");
2687        }
2688    }
2689
2690    // =========================================================================
2691    // Edge case: invalid regex in replace_string
2692    // =========================================================================
2693
2694    #[test]
2695    fn test_replace_string_invalid_regex() {
2696        let mut rule = make_test_rule();
2697        let mut state = PipelineState::default();
2698        let t = Transformation::ReplaceString {
2699            regex: r"[invalid".to_string(), // unclosed bracket
2700            replacement: "x".to_string(),
2701            skip_special: false,
2702        };
2703        let result = t.apply(&mut rule, &mut state, &[], &[], false);
2704        assert!(result.is_err());
2705        let err = result.unwrap_err().to_string();
2706        assert!(
2707            err.contains("bad regex"),
2708            "error should mention regex: {err}"
2709        );
2710    }
2711
2712    // =========================================================================
2713    // Edge case: detection_item_conditions with negate
2714    // =========================================================================
2715
2716    #[test]
2717    fn test_case_transformation_with_negated_match_string() {
2718        let mut rule = make_test_rule();
2719        let mut state = PipelineState::default();
2720
2721        // Negate: transform items that do NOT match "whoami"
2722        let det_conds = vec![DetectionItemCondition::MatchString {
2723            regex: regex::Regex::new("whoami").unwrap(),
2724            negate: true,
2725        }];
2726
2727        let t = Transformation::CaseTransformation {
2728            case_type: "upper".to_string(),
2729        };
2730        t.apply(&mut rule, &mut state, &det_conds, &[], false)
2731            .unwrap();
2732
2733        let det = &rule.detection.named["selection"];
2734        if let Detection::AllOf(items) = det {
2735            // CommandLine has "whoami" → negate means NOT matched → unchanged
2736            if let SigmaValue::String(s) = &items[0].values[0] {
2737                assert_eq!(s.original, "whoami");
2738            }
2739            // ParentImage has "\\cmd.exe" → negate means matched → uppercased
2740            if let SigmaValue::String(s) = &items[1].values[0] {
2741                assert_eq!(s.original, "\\CMD.EXE");
2742            }
2743        } else {
2744            panic!("Expected AllOf");
2745        }
2746    }
2747
2748    // =========================================================================
2749    // Integration: multi-transformation chaining pipeline (YAML)
2750    // =========================================================================
2751
2752    #[test]
2753    fn test_multi_transformation_chaining_pipeline() {
2754        use crate::pipeline::parse_pipeline;
2755
2756        let yaml = r#"
2757name: Multi-step Pipeline
2758transformations:
2759  - id: step1_map
2760    type: field_name_mapping
2761    mapping:
2762      CommandLine: process.command_line
2763      ParentImage: process.parent.executable
2764  - id: step2_prefix
2765    type: field_name_prefix
2766    prefix: "winlog."
2767    rule_conditions:
2768      - type: logsource
2769        product: windows
2770  - id: step3_case
2771    type: case_transformation
2772    case_type: upper
2773    field_name_conditions:
2774      - type: include_fields
2775        fields:
2776          - winlog.process.command_line
2777  - id: step4_attr
2778    type: set_custom_attribute
2779    attribute: rsigma.processed
2780    value: "true"
2781"#;
2782        let pipeline = parse_pipeline(yaml).unwrap();
2783
2784        let mut rule = make_test_rule(); // Windows process_creation rule
2785        let mut state = PipelineState::new(pipeline.vars.clone());
2786        pipeline.apply(&mut rule, &mut state).unwrap();
2787
2788        let det = &rule.detection.named["selection"];
2789        if let Detection::AllOf(items) = det {
2790            // step1: CommandLine → process.command_line
2791            // step2: process.command_line → winlog.process.command_line
2792            assert_eq!(
2793                items[0].field.name,
2794                Some("winlog.process.command_line".to_string())
2795            );
2796            // step3: case upper only on winlog.process.command_line
2797            if let SigmaValue::String(s) = &items[0].values[0] {
2798                assert_eq!(s.original, "WHOAMI");
2799            }
2800
2801            // ParentImage → process.parent.executable → winlog.process.parent.executable
2802            assert_eq!(
2803                items[1].field.name,
2804                Some("winlog.process.parent.executable".to_string())
2805            );
2806            // step3 does NOT apply to this field → value unchanged
2807            if let SigmaValue::String(s) = &items[1].values[0] {
2808                assert_eq!(s.original, "\\cmd.exe");
2809            }
2810        } else {
2811            panic!("Expected AllOf");
2812        }
2813
2814        // step4: custom attribute was set
2815        assert_eq!(
2816            rule.custom_attributes
2817                .get("rsigma.processed")
2818                .and_then(|v| v.as_str()),
2819            Some("true")
2820        );
2821
2822        // All steps should be tracked
2823        assert!(state.was_applied("step1_map"));
2824        assert!(state.was_applied("step2_prefix"));
2825        assert!(state.was_applied("step3_case"));
2826        assert!(state.was_applied("step4_attr"));
2827    }
2828
2829    // =========================================================================
2830    // MapString one-to-many tests
2831    // =========================================================================
2832
2833    #[test]
2834    fn test_map_string_one_to_many() {
2835        let mut rule = make_test_rule();
2836        let mut state = PipelineState::default();
2837        let mut mapping = HashMap::new();
2838        mapping.insert(
2839            "whoami".to_string(),
2840            vec!["who".to_string(), "am_i".to_string(), "test".to_string()],
2841        );
2842        let t = Transformation::MapString { mapping };
2843        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2844
2845        let det = &rule.detection.named["selection"];
2846        if let Detection::AllOf(items) = det {
2847            // Original single value should expand to 3 values
2848            assert_eq!(items[0].values.len(), 3);
2849            if let SigmaValue::String(s) = &items[0].values[0] {
2850                assert_eq!(s.original, "who");
2851            }
2852            if let SigmaValue::String(s) = &items[0].values[1] {
2853                assert_eq!(s.original, "am_i");
2854            }
2855            if let SigmaValue::String(s) = &items[0].values[2] {
2856                assert_eq!(s.original, "test");
2857            }
2858        } else {
2859            panic!("Expected AllOf");
2860        }
2861    }
2862
2863    #[test]
2864    fn test_map_string_one_to_many_mixed() {
2865        // Test that non-matching values remain and only matching ones expand
2866        let yaml = r#"
2867title: Test Rule
2868logsource:
2869    product: windows
2870detection:
2871    selection:
2872        CommandLine:
2873            - whoami
2874            - ipconfig
2875    condition: selection
2876level: medium
2877"#;
2878        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
2879        let mut rule = collection.rules[0].clone();
2880        let mut state = PipelineState::default();
2881        let mut mapping = HashMap::new();
2882        mapping.insert(
2883            "whoami".to_string(),
2884            vec!["who".to_string(), "am_i".to_string()],
2885        );
2886        // ipconfig is not in the mapping, should remain unchanged
2887        let t = Transformation::MapString { mapping };
2888        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2889
2890        let det = &rule.detection.named["selection"];
2891        if let Detection::AllOf(items) = det {
2892            // "whoami" expanded to 2 + "ipconfig" stays = 3 total
2893            assert_eq!(items[0].values.len(), 3);
2894            if let SigmaValue::String(s) = &items[0].values[0] {
2895                assert_eq!(s.original, "who");
2896            }
2897            if let SigmaValue::String(s) = &items[0].values[1] {
2898                assert_eq!(s.original, "am_i");
2899            }
2900            if let SigmaValue::String(s) = &items[0].values[2] {
2901                assert_eq!(s.original, "ipconfig");
2902            }
2903        } else {
2904            panic!("Expected AllOf");
2905        }
2906    }
2907
2908    // =========================================================================
2909    // ReplaceString skip_special tests
2910    // =========================================================================
2911
2912    #[test]
2913    fn test_replace_string_skip_special_preserves_wildcards() {
2914        // Value with wildcards written directly in YAML: "*whoami*"
2915        let yaml = r#"
2916title: Test Rule
2917logsource:
2918    product: windows
2919detection:
2920    selection:
2921        CommandLine: '*whoami*'
2922    condition: selection
2923level: medium
2924"#;
2925        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
2926        let mut rule = collection.rules[0].clone();
2927        let mut state = PipelineState::default();
2928        let t = Transformation::ReplaceString {
2929            regex: r"whoami".to_string(),
2930            replacement: "REPLACED".to_string(),
2931            skip_special: true,
2932        };
2933        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2934
2935        let det = &rule.detection.named["selection"];
2936        if let Detection::AllOf(items) = det {
2937            let s = match &items[0].values[0] {
2938                SigmaValue::String(s) => s,
2939                _ => panic!("Expected String"),
2940            };
2941            // Wildcards should be preserved, plain part replaced
2942            assert!(s.contains_wildcards(), "Wildcards should be preserved");
2943            assert!(
2944                s.original.contains("REPLACED"),
2945                "Plain part should be replaced, got: {}",
2946                s.original
2947            );
2948            assert!(
2949                !s.original.contains("whoami"),
2950                "Original text should be gone"
2951            );
2952        } else {
2953            panic!("Expected AllOf");
2954        }
2955    }
2956
2957    #[test]
2958    fn test_replace_string_skip_special_false_replaces_whole() {
2959        // Without skip_special, the entire original is replaced (wildcards treated as text)
2960        let yaml = r#"
2961title: Test Rule
2962logsource:
2963    product: windows
2964detection:
2965    selection:
2966        CommandLine: '*whoami*'
2967    condition: selection
2968level: medium
2969"#;
2970        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
2971        let mut rule = collection.rules[0].clone();
2972        let mut state = PipelineState::default();
2973        let t = Transformation::ReplaceString {
2974            regex: r"\*".to_string(),
2975            replacement: "STAR".to_string(),
2976            skip_special: false,
2977        };
2978        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
2979
2980        let det = &rule.detection.named["selection"];
2981        if let Detection::AllOf(items) = det {
2982            let s = match &items[0].values[0] {
2983                SigmaValue::String(s) => s,
2984                _ => panic!("Expected String"),
2985            };
2986            // skip_special=false replaces on the original string (which has literal * chars)
2987            assert!(
2988                s.original.contains("STAR"),
2989                "Wildcards in original should be replaced as text, got: {}",
2990                s.original
2991            );
2992        } else {
2993            panic!("Expected AllOf");
2994        }
2995    }
2996
2997    #[test]
2998    fn test_replace_string_skip_special_plain_string() {
2999        // Plain string (no wildcards) with skip_special=true → should still replace
3000        let mut rule = make_test_rule();
3001        let mut state = PipelineState::default();
3002        let t = Transformation::ReplaceString {
3003            regex: r"whoami".to_string(),
3004            replacement: "REPLACED".to_string(),
3005            skip_special: true,
3006        };
3007        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
3008
3009        let det = &rule.detection.named["selection"];
3010        if let Detection::AllOf(items) = det
3011            && let SigmaValue::String(s) = &items[0].values[0]
3012        {
3013            assert_eq!(s.original, "REPLACED");
3014        }
3015    }
3016
3017    // =========================================================================
3018    // CaseTransformation snake_case tests
3019    // =========================================================================
3020
3021    #[test]
3022    fn test_case_transformation_snake_case() {
3023        let yaml = r#"
3024title: Test Rule
3025logsource:
3026    product: windows
3027detection:
3028    selection:
3029        CommandLine: CommandAndControl
3030    condition: selection
3031level: medium
3032"#;
3033        let collection = rsigma_parser::parse_sigma_yaml(yaml).unwrap();
3034        let mut rule = collection.rules[0].clone();
3035        let mut state = PipelineState::default();
3036        let t = Transformation::CaseTransformation {
3037            case_type: "snake_case".to_string(),
3038        };
3039        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
3040
3041        let det = &rule.detection.named["selection"];
3042        if let Detection::AllOf(items) = det {
3043            if let SigmaValue::String(s) = &items[0].values[0] {
3044                assert_eq!(s.original, "command_and_control");
3045            } else {
3046                panic!("Expected String");
3047            }
3048        } else {
3049            panic!("Expected AllOf");
3050        }
3051    }
3052
3053    #[test]
3054    fn test_case_transformation_snake_case_already_lowercase() {
3055        let mut rule = make_test_rule(); // "whoami" is already lowercase
3056        let mut state = PipelineState::default();
3057        let t = Transformation::CaseTransformation {
3058            case_type: "snake_case".to_string(),
3059        };
3060        t.apply(&mut rule, &mut state, &[], &[], false).unwrap();
3061
3062        let det = &rule.detection.named["selection"];
3063        if let Detection::AllOf(items) = det
3064            && let SigmaValue::String(s) = &items[0].values[0]
3065        {
3066            assert_eq!(s.original, "whoami"); // unchanged
3067        }
3068    }
3069}