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