Skip to main content

rsigma_parser/
emit.rs

1//! Emit a parsed Sigma rule back to canonical Sigma YAML.
2//!
3//! This is the inverse of [`parse_sigma_yaml`](crate::parse_sigma_yaml): it
4//! turns a [`SigmaRule`] (or a [`SigmaCollection`] of detection rules) back
5//! into standard Sigma YAML. Parsing then emitting then parsing again yields an
6//! equal AST for the detection-rule shapes the parser produces (field matching
7//! with modifiers, value lists, keyword blocks, `field[any]`/`field[all]` array
8//! blocks, boolean and quantified conditions, and all standard metadata).
9//!
10//! The emitter is deterministic: mapping-shaped collections (named detections,
11//! logsource custom fields, custom attributes) are emitted in sorted key order,
12//! so the same rule always produces byte-identical YAML. Detection value order
13//! and condition order are preserved as-is.
14//!
15//! Value scalars are rendered in Sigma's single-quote convention, with literal
16//! `*`, `?`, and `\` escaped so a re-parse reproduces the same
17//! [`SigmaString`] wildcard structure.
18
19use std::collections::BTreeMap;
20use std::fmt::Write as _;
21
22use crate::ast::{
23    ArrayQuantifier, ConditionExpr, Detection, DetectionItem, Detections, LogSource, Modifier,
24    Related, RelationType, SigmaCollection, SigmaRule, Status,
25};
26use crate::value::{SigmaString, SigmaValue, SpecialChar, StringPart};
27
28/// One indentation level (four spaces, matching the SigmaHQ house style).
29const STEP: &str = "    ";
30
31/// Emit a single detection [`SigmaRule`] as canonical Sigma YAML.
32///
33/// The output always ends with a trailing newline and re-parses to an equal
34/// [`SigmaRule`].
35pub fn emit_rule_yaml(rule: &SigmaRule) -> String {
36    let mut out = String::new();
37
38    push_line(&mut out, "title", &scalar_prose(&rule.title));
39    if let Some(id) = &rule.id {
40        push_line(&mut out, "id", &scalar(id));
41    }
42    if let Some(name) = &rule.name {
43        push_line(&mut out, "name", &scalar(name));
44    }
45    if let Some(status) = &rule.status {
46        push_line(&mut out, "status", status_str(*status));
47    }
48    if let Some(description) = &rule.description {
49        emit_scalar_field(&mut out, "description", description, "");
50    }
51    emit_string_list(&mut out, "references", &rule.references);
52    if let Some(author) = &rule.author {
53        emit_scalar_field(&mut out, "author", author, "");
54    }
55    if let Some(date) = &rule.date {
56        push_line(&mut out, "date", &scalar(date));
57    }
58    if let Some(modified) = &rule.modified {
59        push_line(&mut out, "modified", &scalar(modified));
60    }
61    emit_related(&mut out, &rule.related);
62    emit_string_list(&mut out, "tags", &rule.tags);
63    if let Some(version) = rule.sigma_version {
64        push_line(&mut out, "sigma-version", &version.to_string());
65    }
66    emit_logsource(&mut out, &rule.logsource);
67    emit_detection(&mut out, &rule.detection);
68    emit_string_list(&mut out, "fields", &rule.fields);
69    emit_string_list(&mut out, "falsepositives", &rule.falsepositives);
70    if let Some(level) = &rule.level {
71        push_line(&mut out, "level", level.as_str());
72    }
73    emit_string_list(&mut out, "scope", &rule.scope);
74    if let Some(license) = &rule.license {
75        emit_scalar_field(&mut out, "license", license, "");
76    }
77    if let Some(taxonomy) = &rule.taxonomy {
78        push_line(&mut out, "taxonomy", &scalar(taxonomy));
79    }
80    emit_custom_attributes(&mut out, rule);
81
82    out
83}
84
85/// Emit every detection rule in a collection, separated by `---` documents.
86///
87/// Correlation and filter documents are not part of the reverse-conversion
88/// surface and are skipped; only [`SigmaCollection::rules`] are emitted.
89pub fn emit_collection_yaml(collection: &SigmaCollection) -> String {
90    collection
91        .rules
92        .iter()
93        .map(emit_rule_yaml)
94        .collect::<Vec<_>>()
95        .join("---\n")
96}
97
98// =============================================================================
99// Metadata sections
100// =============================================================================
101
102fn push_line(out: &mut String, key: &str, value: &str) {
103    let _ = writeln!(out, "{key}: {value}");
104}
105
106fn emit_scalar_field(out: &mut String, key: &str, value: &str, indent: &str) {
107    if value.contains('\n') {
108        let _ = writeln!(out, "{indent}{key}: |-");
109        for line in value.split('\n') {
110            let _ = writeln!(out, "{indent}{STEP}{line}");
111        }
112    } else {
113        let _ = writeln!(out, "{indent}{key}: {}", scalar_prose(value));
114    }
115}
116
117fn emit_string_list(out: &mut String, key: &str, items: &[String]) {
118    if items.is_empty() {
119        return;
120    }
121    let _ = writeln!(out, "{key}:");
122    for item in items {
123        let _ = writeln!(out, "{STEP}- {}", scalar_prose(item));
124    }
125}
126
127fn emit_related(out: &mut String, related: &[Related]) {
128    if related.is_empty() {
129        return;
130    }
131    let _ = writeln!(out, "related:");
132    for entry in related {
133        let _ = writeln!(out, "{STEP}- id: {}", scalar(&entry.id));
134        let _ = writeln!(out, "{STEP}  type: {}", relation_str(entry.relation_type));
135    }
136}
137
138fn emit_logsource(out: &mut String, logsource: &LogSource) {
139    // An empty `logsource:` key parses as null, which the parser reports as
140    // not being a mapping; an explicit empty mapping gets the clearer error
141    // that no log source field is set.
142    if logsource.category.is_none()
143        && logsource.product.is_none()
144        && logsource.service.is_none()
145        && logsource.definition.is_none()
146        && logsource.custom.is_empty()
147    {
148        let _ = writeln!(out, "logsource: {{}}");
149        return;
150    }
151    let _ = writeln!(out, "logsource:");
152    if let Some(category) = &logsource.category {
153        let _ = writeln!(out, "{STEP}category: {}", scalar(category));
154    }
155    if let Some(product) = &logsource.product {
156        let _ = writeln!(out, "{STEP}product: {}", scalar(product));
157    }
158    if let Some(service) = &logsource.service {
159        let _ = writeln!(out, "{STEP}service: {}", scalar(service));
160    }
161    if let Some(definition) = &logsource.definition {
162        emit_scalar_field(out, "definition", definition, STEP);
163    }
164    for (key, value) in sorted(&logsource.custom) {
165        let _ = writeln!(out, "{STEP}{}: {}", key_token(key), scalar(value));
166    }
167}
168
169fn emit_custom_attributes(out: &mut String, rule: &SigmaRule) {
170    let mut keys: Vec<&String> = rule.custom_attributes.keys().collect();
171    keys.sort();
172    for key in keys {
173        let value = &rule.custom_attributes[key];
174        emit_yaml_value(out, &key_token(key), value, "");
175    }
176}
177
178// =============================================================================
179// Detection section
180// =============================================================================
181
182fn emit_detection(out: &mut String, detection: &Detections) {
183    let _ = writeln!(out, "detection:");
184    for (name, det) in sorted_named(&detection.named) {
185        emit_named_detection(out, name, det, STEP);
186    }
187    emit_condition(out, &detection.conditions);
188}
189
190fn emit_condition(out: &mut String, conditions: &[ConditionExpr]) {
191    match conditions {
192        [] => {}
193        [single] => {
194            let _ = writeln!(out, "{STEP}condition: {}", condition_source(single));
195        }
196        many => {
197            let _ = writeln!(out, "{STEP}condition:");
198            for cond in many {
199                let _ = writeln!(out, "{STEP}{STEP}- {}", condition_source(cond));
200            }
201        }
202    }
203}
204
205/// Render a condition expression as a Sigma condition string, without the
206/// redundant outer parentheses [`ConditionExpr`]'s `Display` adds around a
207/// top-level `and`/`or`.
208fn condition_source(expr: &ConditionExpr) -> String {
209    match expr {
210        ConditionExpr::And(parts) => parts
211            .iter()
212            .map(|p| p.to_string())
213            .collect::<Vec<_>>()
214            .join(" and "),
215        ConditionExpr::Or(parts) => parts
216            .iter()
217            .map(|p| p.to_string())
218            .collect::<Vec<_>>()
219            .join(" or "),
220        other => other.to_string(),
221    }
222}
223
224fn emit_named_detection(out: &mut String, name: &str, det: &Detection, indent: &str) {
225    let _ = writeln!(out, "{indent}{}:", key_token(name));
226    emit_detection_value(out, det, &deeper(indent));
227}
228
229/// Emit the body of a detection at `indent` (a mapping of items, a YAML list,
230/// or a keyword list depending on the detection shape).
231fn emit_detection_value(out: &mut String, det: &Detection, indent: &str) {
232    match det {
233        Detection::AnyOf(subs) => {
234            for sub in subs {
235                emit_list_item(out, sub, indent);
236            }
237        }
238        Detection::Keywords(values) => {
239            for value in values {
240                let _ = writeln!(out, "{indent}- {}", value_token(value));
241            }
242        }
243        map_shaped => emit_map_entries(out, map_shaped, indent),
244    }
245}
246
247/// Emit the `key: value` entries of a mapping-shaped detection.
248fn emit_map_entries(out: &mut String, det: &Detection, indent: &str) {
249    match det {
250        Detection::AllOf(items) => {
251            for item in items {
252                emit_item(out, item, indent);
253            }
254        }
255        Detection::And(subs) => {
256            for sub in subs {
257                emit_map_entries(out, sub, indent);
258            }
259        }
260        Detection::ArrayMatch {
261            field,
262            quantifier,
263            body,
264        } => {
265            let _ = writeln!(
266                out,
267                "{indent}{}[{}]:",
268                field_token(field),
269                array_str(*quantifier)
270            );
271            emit_detection_value(out, body, &deeper(indent));
272        }
273        Detection::Conditional { named, condition } => {
274            for (name, sub) in sorted_named(named) {
275                emit_named_detection(out, name, sub, indent);
276            }
277            let _ = writeln!(out, "{indent}condition: {}", condition_source(condition));
278        }
279        // List-shaped detections cannot appear as bare map entries; render them
280        // under a synthetic block so nothing is silently dropped.
281        Detection::AnyOf(_) | Detection::Keywords(_) => {
282            emit_detection_value(out, det, indent);
283        }
284    }
285}
286
287/// Emit one YAML list item (`- ...`) for an `AnyOf` sub-detection.
288fn emit_list_item(out: &mut String, det: &Detection, indent: &str) {
289    let mut buf = String::new();
290    emit_detection_value(&mut buf, det, "");
291    for (i, line) in buf.lines().enumerate() {
292        if i == 0 {
293            let _ = writeln!(out, "{indent}- {line}");
294        } else {
295            let _ = writeln!(out, "{indent}  {line}");
296        }
297    }
298}
299
300/// Emit a single detection item (`field|mods: value` or a value list).
301fn emit_item(out: &mut String, item: &DetectionItem, indent: &str) {
302    let base = item.field.name.as_deref().unwrap_or(".");
303    let key = field_key(base, &item.field.modifiers);
304    // `re`, `cidr`, and `fieldref` values are raw strings (the parser reads them
305    // without wildcard interpretation), so they must be emitted verbatim rather
306    // than wildcard-escaped, or a regex like `ab.*c` would gain a stray `\`.
307    let raw = item
308        .field
309        .modifiers
310        .iter()
311        .any(|m| matches!(m, Modifier::Re | Modifier::Cidr | Modifier::FieldRef));
312    let expand = item.field.modifiers.contains(&Modifier::Expand);
313    let token = |value: &SigmaValue| match value {
314        // Placeholder escapes are only unambiguous in the source text.
315        SigmaValue::String(s) if expand => scalar(&s.original),
316        _ => value_token_ctx(value, raw),
317    };
318    match item.values.as_slice() {
319        [single] => {
320            let _ = writeln!(out, "{indent}{key}: {}", token(single));
321        }
322        values => {
323            let _ = writeln!(out, "{indent}{key}:");
324            for value in values {
325                let _ = writeln!(out, "{indent}{STEP}- {}", token(value));
326            }
327        }
328    }
329}
330
331// =============================================================================
332// yaml_serde value emission (custom attributes)
333// =============================================================================
334
335fn emit_yaml_value(out: &mut String, key: &str, value: &yaml_serde::Value, indent: &str) {
336    match value {
337        yaml_serde::Value::Mapping(map) if !map.is_empty() => {
338            let _ = writeln!(out, "{indent}{key}:");
339            for (k, v) in map {
340                let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
341                emit_yaml_value(out, &child_key, v, &deeper(indent));
342            }
343        }
344        yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
345            let _ = writeln!(out, "{indent}{key}:");
346            for v in seq {
347                emit_yaml_seq_item(out, v, &deeper(indent));
348            }
349        }
350        scalar => {
351            let _ = writeln!(out, "{indent}{key}: {}", yaml_scalar(scalar));
352        }
353    }
354}
355
356/// Emit one YAML sequence item, including nested mappings and sequences.
357fn emit_yaml_seq_item(out: &mut String, value: &yaml_serde::Value, indent: &str) {
358    match value {
359        yaml_serde::Value::Mapping(map) if !map.is_empty() => {
360            let mut buf = String::new();
361            for (k, v) in map {
362                let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
363                emit_yaml_value(&mut buf, &child_key, v, "");
364            }
365            for (i, line) in buf.lines().enumerate() {
366                if i == 0 {
367                    let _ = writeln!(out, "{indent}- {line}");
368                } else {
369                    let _ = writeln!(out, "{indent}  {line}");
370                }
371            }
372        }
373        yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
374            let _ = writeln!(out, "{indent}-");
375            for v in seq {
376                emit_yaml_seq_item(out, v, &deeper(indent));
377            }
378        }
379        scalar => {
380            let _ = writeln!(out, "{indent}- {}", yaml_scalar(scalar));
381        }
382    }
383}
384
385fn yaml_scalar(value: &yaml_serde::Value) -> String {
386    match value {
387        yaml_serde::Value::Null => "null".to_string(),
388        yaml_serde::Value::Bool(b) => b.to_string(),
389        yaml_serde::Value::Number(n) => n.to_string(),
390        yaml_serde::Value::String(s) => scalar(s),
391        // Nested collections are handled by emit_yaml_value; an inline fallback
392        // keeps the emitter total for unexpected placements.
393        other => scalar(&format!("{other:?}")),
394    }
395}
396
397// =============================================================================
398// Scalars, keys, and value tokens
399// =============================================================================
400
401/// Render a [`SigmaValue`] as a YAML token.
402fn value_token(value: &SigmaValue) -> String {
403    value_token_ctx(value, false)
404}
405
406/// Render a [`SigmaValue`] as a YAML token. When `raw` is set the string is a
407/// raw value (a regex, CIDR, or field reference) and is emitted verbatim rather
408/// than with Sigma wildcard escaping.
409fn value_token_ctx(value: &SigmaValue, raw: bool) -> String {
410    match value {
411        SigmaValue::String(s) if raw => scalar(&s.as_plain().unwrap_or_else(|| s.original.clone())),
412        SigmaValue::String(s) => scalar(&sigma_string_source(s)),
413        SigmaValue::Integer(n) => n.to_string(),
414        SigmaValue::Float(f) => float_token(*f),
415        SigmaValue::Bool(b) => b.to_string(),
416        SigmaValue::Null => "null".to_string(),
417    }
418}
419
420/// Format a float so it re-parses as a float (never collapses `3.0` to `3`).
421fn float_token(f: f64) -> String {
422    let s = f.to_string();
423    if s.contains(['.', 'e', 'E']) || s.contains("inf") || s.contains("NaN") {
424        s
425    } else {
426        format!("{s}.0")
427    }
428}
429
430/// Reconstruct the Sigma source text of a [`SigmaString`], escaping literal
431/// wildcard and backslash characters so the value round-trips through the
432/// parser unchanged.
433fn sigma_string_source(value: &SigmaString) -> String {
434    let mut out = String::with_capacity(value.original.len());
435    for part in &value.parts {
436        match part {
437            StringPart::Plain(text) => push_escaped_literal(&mut out, text),
438            StringPart::Special(SpecialChar::WildcardMulti) => out.push('*'),
439            StringPart::Special(SpecialChar::WildcardSingle) => out.push('?'),
440        }
441    }
442    out
443}
444
445/// Escape a literal segment: `*`/`?` always gain a backslash; a run of
446/// backslashes is doubled only when it would otherwise bind to a following
447/// wildcard or end the value (keeping plain Windows paths readable).
448fn push_escaped_literal(out: &mut String, text: &str) {
449    let chars: Vec<char> = text.chars().collect();
450    let mut i = 0;
451    while i < chars.len() {
452        match chars[i] {
453            '*' => out.push_str("\\*"),
454            '?' => out.push_str("\\?"),
455            '\\' => {
456                let mut j = i;
457                while j < chars.len() && chars[j] == '\\' {
458                    j += 1;
459                }
460                let run = j - i;
461                let next = chars.get(j);
462                let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
463                for _ in 0..run {
464                    out.push_str(if must_escape { "\\\\" } else { "\\" });
465                }
466                i = j;
467                continue;
468            }
469            other => out.push(other),
470        }
471        i += 1;
472    }
473}
474
475/// Quote a value scalar in Sigma's single-quote convention unless it is a
476/// bare-safe token.
477fn scalar(s: &str) -> String {
478    if is_bare_safe(s) {
479        s.to_string()
480    } else {
481        quote(s)
482    }
483}
484
485/// Looser quoting for prose scalars (title, description, author): plain YAML
486/// permits internal spaces, so common values stay unquoted.
487fn scalar_prose(s: &str) -> String {
488    let bare = !s.is_empty()
489        && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
490        && !s.ends_with(' ')
491        && !s.contains(": ")
492        && !s.contains(" #")
493        && !s.contains('\n')
494        && s.chars().all(|c| {
495            c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')' | '/')
496        });
497    if bare { s.to_string() } else { quote(s) }
498}
499
500fn quote(s: &str) -> String {
501    format!("'{}'", s.replace('\'', "''"))
502}
503
504fn is_bare_safe(s: &str) -> bool {
505    !s.is_empty()
506        && s.chars()
507            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
508        && !s.starts_with('-')
509        && s.parse::<f64>().is_err()
510        && !matches!(
511            s.to_ascii_lowercase().as_str(),
512            "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~"
513        )
514}
515
516/// A mapping key (metadata key, custom-attribute key, or logsource custom
517/// field). Quoted only when it contains characters unsafe in a plain key.
518fn key_token(s: &str) -> String {
519    let safe = !s.is_empty()
520        && s.chars()
521            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
522    if safe { s.to_string() } else { quote(s) }
523}
524
525/// A detection field name used inside a key (bare identifiers, dotted paths,
526/// and array/index markers pass through; anything else is quoted).
527fn field_token(s: &str) -> String {
528    let safe = !s.is_empty()
529        && s.chars().all(|c| {
530            c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '[' | ']' | '@')
531        });
532    if safe { s.to_string() } else { quote(s) }
533}
534
535/// Build a detection key from a field name and its ordered modifiers.
536fn field_key(field: &str, modifiers: &[Modifier]) -> String {
537    let mut key = String::new();
538    for (i, part) in field.split('|').enumerate() {
539        if i > 0 {
540            key.push('|');
541        }
542        key.push_str(&field_token(part));
543    }
544    for modifier in modifiers {
545        key.push('|');
546        key.push_str(modifier_str(*modifier));
547    }
548    key
549}
550
551pub(crate) fn modifier_str(modifier: Modifier) -> &'static str {
552    match modifier {
553        Modifier::Contains => "contains",
554        Modifier::StartsWith => "startswith",
555        Modifier::EndsWith => "endswith",
556        Modifier::All => "all",
557        Modifier::Base64 => "base64",
558        Modifier::Base64Offset => "base64offset",
559        Modifier::Wide => "wide",
560        Modifier::Utf16be => "utf16be",
561        Modifier::Utf16 => "utf16",
562        Modifier::WindAsh => "windash",
563        Modifier::Re => "re",
564        Modifier::Cidr => "cidr",
565        Modifier::Cased => "cased",
566        Modifier::Exists => "exists",
567        Modifier::Expand => "expand",
568        Modifier::FieldRef => "fieldref",
569        Modifier::Gt => "gt",
570        Modifier::Gte => "gte",
571        Modifier::Lt => "lt",
572        Modifier::Lte => "lte",
573        Modifier::Neq => "neq",
574        Modifier::IgnoreCase => "i",
575        Modifier::Multiline => "m",
576        Modifier::DotAll => "s",
577        Modifier::Minute => "minute",
578        Modifier::Hour => "hour",
579        Modifier::Day => "day",
580        Modifier::Week => "week",
581        Modifier::Month => "month",
582        Modifier::Year => "year",
583    }
584}
585
586fn status_str(status: Status) -> &'static str {
587    match status {
588        Status::Stable => "stable",
589        Status::Test => "test",
590        Status::Experimental => "experimental",
591        Status::Deprecated => "deprecated",
592        Status::Unsupported => "unsupported",
593    }
594}
595
596fn relation_str(relation: RelationType) -> &'static str {
597    match relation {
598        RelationType::Correlation => "correlation",
599        RelationType::Derived => "derived",
600        RelationType::Obsolete => "obsolete",
601        RelationType::Merged => "merged",
602        RelationType::Renamed => "renamed",
603        RelationType::Similar => "similar",
604    }
605}
606
607fn array_str(quantifier: ArrayQuantifier) -> &'static str {
608    match quantifier {
609        ArrayQuantifier::Any => "any",
610        ArrayQuantifier::All => "all",
611        ArrayQuantifier::AllOrEmpty => "all_or_empty",
612        ArrayQuantifier::None => "none",
613    }
614}
615
616// =============================================================================
617// Small helpers
618// =============================================================================
619
620fn deeper(indent: &str) -> String {
621    format!("{indent}{STEP}")
622}
623
624fn sorted(map: &std::collections::HashMap<String, String>) -> Vec<(&str, &str)> {
625    let mut entries: Vec<(&str, &str)> =
626        map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
627    entries.sort_by(|a, b| a.0.cmp(b.0));
628    entries
629}
630
631fn sorted_named(map: &std::collections::HashMap<String, Detection>) -> Vec<(&str, &Detection)> {
632    let mut entries: BTreeMap<&str, &Detection> = BTreeMap::new();
633    for (k, v) in map {
634        entries.insert(k.as_str(), v);
635    }
636    entries.into_iter().collect()
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::parse_sigma_yaml;
643
644    /// Assert the emitter is a stable canonical form: `emit(parse(x))` re-parses
645    /// to one rule and re-emits to a byte-identical string. This is the correct
646    /// round-trip criterion; it does not depend on the raw source spelling a
647    /// [`SigmaString`] preserves in its `original` field.
648    fn assert_round_trips(yaml: &str) -> String {
649        let first = parse_sigma_yaml(yaml).expect("input parses");
650        assert_eq!(first.rules.len(), 1, "expected one input rule");
651        let emitted = emit_rule_yaml(&first.rules[0]);
652
653        let reparsed = parse_sigma_yaml(&emitted)
654            .unwrap_or_else(|e| panic!("emitted YAML must re-parse: {e}\n---\n{emitted}"));
655        assert_eq!(
656            reparsed.rules.len(),
657            1,
658            "expected one rule, got:\n{emitted}"
659        );
660
661        let reemitted = emit_rule_yaml(&reparsed.rules[0]);
662        assert_eq!(emitted, reemitted, "emit is not idempotent:\n{emitted}");
663        emitted
664    }
665
666    #[test]
667    fn round_trips_minimal_rule() {
668        assert_round_trips(
669            "title: Whoami\nlogsource:\n    product: windows\n    category: process_creation\ndetection:\n    selection:\n        CommandLine|contains: whoami\n    condition: selection\nlevel: medium\n",
670        );
671    }
672
673    #[test]
674    fn round_trips_modifiers_and_value_lists() {
675        assert_round_trips(
676            "title: Modifiers\nlogsource:\n    product: windows\ndetection:\n    selection:\n        Image|endswith:\n            - '\\\\cmd.exe'\n            - '\\\\powershell.exe'\n        CommandLine|contains|all:\n            - foo\n            - bar\n        Field|re: 'ab.*c'\n        Port|gt: 1024\n        User|cased: Admin\n    filter:\n        Image|startswith: 'C:\\\\Windows\\\\'\n    condition: selection and not filter\nlevel: high\n",
677        );
678    }
679
680    #[test]
681    fn round_trips_keywords_and_anyof() {
682        assert_round_trips(
683            "title: Keywords\nlogsource:\n    product: linux\ndetection:\n    keywords:\n        - mimikatz\n        - sekurlsa\n    selection:\n        - EventID: 1\n        - EventID: 4688\n    condition: keywords and selection\n",
684        );
685    }
686
687    #[test]
688    fn round_trips_metadata_and_selector_condition() {
689        assert_round_trips(
690            "title: Full Metadata\nid: 11111111-2222-3333-4444-555555555555\nstatus: experimental\ndescription: A single line description.\nreferences:\n    - https://example.com/a\nauthor: Jane Doe\ndate: 2026-01-01\ntags:\n    - attack.execution\n    - attack.t1059\nlogsource:\n    product: windows\n    category: process_creation\ndetection:\n    selection_a:\n        Image|endswith: '\\\\a.exe'\n    selection_b:\n        Image|endswith: '\\\\b.exe'\n    condition: 1 of selection_*\nfalsepositives:\n    - Legitimate admin use\nlevel: low\n",
691        );
692    }
693
694    #[test]
695    fn escapes_wildcards_in_literal_values() {
696        // A literal asterisk in the value must survive as a literal, not a wildcard.
697        let yaml = "title: Escapes\nlogsource:\n    product: test\ndetection:\n    selection:\n        Field: 'a\\*b'\n    condition: selection\n";
698        let emitted = assert_round_trips(yaml);
699        assert!(
700            emitted.contains(r"a\*b"),
701            "expected escaped glob, got:\n{emitted}"
702        );
703    }
704
705    #[test]
706    fn empty_logsource_emits_a_mapping_the_parser_rejects() {
707        let mut rule = parse_sigma_yaml(
708            "title: No Logsource\nlogsource:\n    category: test\ndetection:\n    selection:\n        Field: value\n    condition: selection\n",
709        )
710        .unwrap()
711        .rules
712        .remove(0);
713        rule.logsource = LogSource::default();
714        let emitted = emit_rule_yaml(&rule);
715        assert!(emitted.contains("logsource: {}"), "{emitted}");
716        let errors = parse_sigma_yaml(&emitted).unwrap().errors;
717        assert!(
718            errors[0].contains("at least one of category, product, or service"),
719            "{errors:?}"
720        );
721    }
722
723    #[test]
724    fn round_trips_detection_exemplars() {
725        let emitted = assert_round_trips(
726            "title: Whoami\nid: 11111111-2222-3333-4444-555555555555\nlogsource:\n    product: windows\n    category: process_creation\ndetection:\n    selection:\n        CommandLine|contains: whoami\n    condition: selection\ncustom_attributes:\n    rsigma.exemplars:\n        - name: whoami fires\n          expect: match\n          event:\n              CommandLine: whoami /all\n        - name: benign hostname\n          expect: no-match\n          event:\n              CommandLine: hostname\n",
727        );
728        assert!(
729            emitted.contains("rsigma.exemplars:"),
730            "expected exemplars in emit:\n{emitted}"
731        );
732        assert!(
733            emitted.contains("whoami fires"),
734            "expected exemplar name:\n{emitted}"
735        );
736        let reparsed = parse_sigma_yaml(&emitted).unwrap();
737        let attrs = &reparsed.rules[0].custom_attributes;
738        let list = crate::exemplar::exemplars_from_attrs(
739            attrs,
740            crate::exemplar::ExemplarRuleKind::Detection,
741        )
742        .expect("emitted exemplars re-parse");
743        assert_eq!(list.len(), 2);
744        assert_eq!(list[0].expect, crate::exemplar::Expect::Match);
745        assert_eq!(list[1].expect, crate::exemplar::Expect::NoMatch);
746    }
747
748    #[test]
749    fn emit_preserves_correlation_shaped_exemplar_sequence() {
750        let emitted = assert_round_trips(
751            "title: Burst host\nlogsource:\n    category: auth\ndetection:\n    selection:\n        EventType: login\n    condition: selection\ncustom_attributes:\n    rsigma.exemplars:\n        - name: burst\n          expect: match\n          event:\n              EventType: login\n              User: alice\n",
752        );
753        assert!(emitted.contains("User:"), "{emitted}");
754    }
755
756    #[test]
757    fn round_trips_array_object_scope_block() {
758        assert_round_trips(
759            "title: Array\nsigma-version: 3\nlogsource:\n    category: test\ndetection:\n    selection:\n        connections[any]:\n            protocol: TCP\n            port: 445\n    condition: selection\n",
760        );
761    }
762}