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 rejects (it
140    // must be a mapping); emit an explicit empty mapping instead.
141    if logsource.category.is_none()
142        && logsource.product.is_none()
143        && logsource.service.is_none()
144        && logsource.definition.is_none()
145        && logsource.custom.is_empty()
146    {
147        let _ = writeln!(out, "logsource: {{}}");
148        return;
149    }
150    let _ = writeln!(out, "logsource:");
151    if let Some(category) = &logsource.category {
152        let _ = writeln!(out, "{STEP}category: {}", scalar(category));
153    }
154    if let Some(product) = &logsource.product {
155        let _ = writeln!(out, "{STEP}product: {}", scalar(product));
156    }
157    if let Some(service) = &logsource.service {
158        let _ = writeln!(out, "{STEP}service: {}", scalar(service));
159    }
160    if let Some(definition) = &logsource.definition {
161        emit_scalar_field(out, "definition", definition, STEP);
162    }
163    for (key, value) in sorted(&logsource.custom) {
164        let _ = writeln!(out, "{STEP}{}: {}", key_token(key), scalar(value));
165    }
166}
167
168fn emit_custom_attributes(out: &mut String, rule: &SigmaRule) {
169    let mut keys: Vec<&String> = rule.custom_attributes.keys().collect();
170    keys.sort();
171    for key in keys {
172        let value = &rule.custom_attributes[key];
173        emit_yaml_value(out, &key_token(key), value, "");
174    }
175}
176
177// =============================================================================
178// Detection section
179// =============================================================================
180
181fn emit_detection(out: &mut String, detection: &Detections) {
182    let _ = writeln!(out, "detection:");
183    for (name, det) in sorted_named(&detection.named) {
184        emit_named_detection(out, name, det, STEP);
185    }
186    emit_condition(out, &detection.conditions);
187}
188
189fn emit_condition(out: &mut String, conditions: &[ConditionExpr]) {
190    match conditions {
191        [] => {}
192        [single] => {
193            let _ = writeln!(out, "{STEP}condition: {}", condition_source(single));
194        }
195        many => {
196            let _ = writeln!(out, "{STEP}condition:");
197            for cond in many {
198                let _ = writeln!(out, "{STEP}{STEP}- {}", condition_source(cond));
199            }
200        }
201    }
202}
203
204/// Render a condition expression as a Sigma condition string, without the
205/// redundant outer parentheses [`ConditionExpr`]'s `Display` adds around a
206/// top-level `and`/`or`.
207fn condition_source(expr: &ConditionExpr) -> String {
208    match expr {
209        ConditionExpr::And(parts) => parts
210            .iter()
211            .map(|p| p.to_string())
212            .collect::<Vec<_>>()
213            .join(" and "),
214        ConditionExpr::Or(parts) => parts
215            .iter()
216            .map(|p| p.to_string())
217            .collect::<Vec<_>>()
218            .join(" or "),
219        other => other.to_string(),
220    }
221}
222
223fn emit_named_detection(out: &mut String, name: &str, det: &Detection, indent: &str) {
224    let _ = writeln!(out, "{indent}{}:", key_token(name));
225    emit_detection_value(out, det, &deeper(indent));
226}
227
228/// Emit the body of a detection at `indent` (a mapping of items, a YAML list,
229/// or a keyword list depending on the detection shape).
230fn emit_detection_value(out: &mut String, det: &Detection, indent: &str) {
231    match det {
232        Detection::AnyOf(subs) => {
233            for sub in subs {
234                emit_list_item(out, sub, indent);
235            }
236        }
237        Detection::Keywords(values) => {
238            for value in values {
239                let _ = writeln!(out, "{indent}- {}", value_token(value));
240            }
241        }
242        map_shaped => emit_map_entries(out, map_shaped, indent),
243    }
244}
245
246/// Emit the `key: value` entries of a mapping-shaped detection.
247fn emit_map_entries(out: &mut String, det: &Detection, indent: &str) {
248    match det {
249        Detection::AllOf(items) => {
250            for item in items {
251                emit_item(out, item, indent);
252            }
253        }
254        Detection::And(subs) => {
255            for sub in subs {
256                emit_map_entries(out, sub, indent);
257            }
258        }
259        Detection::ArrayMatch {
260            field,
261            quantifier,
262            body,
263        } => {
264            let _ = writeln!(
265                out,
266                "{indent}{}[{}]:",
267                field_token(field),
268                array_str(*quantifier)
269            );
270            emit_detection_value(out, body, &deeper(indent));
271        }
272        Detection::Conditional { named, condition } => {
273            for (name, sub) in sorted_named(named) {
274                emit_named_detection(out, name, sub, indent);
275            }
276            let _ = writeln!(out, "{indent}condition: {}", condition_source(condition));
277        }
278        // List-shaped detections cannot appear as bare map entries; render them
279        // under a synthetic block so nothing is silently dropped.
280        Detection::AnyOf(_) | Detection::Keywords(_) => {
281            emit_detection_value(out, det, indent);
282        }
283    }
284}
285
286/// Emit one YAML list item (`- ...`) for an `AnyOf` sub-detection.
287fn emit_list_item(out: &mut String, det: &Detection, indent: &str) {
288    let mut buf = String::new();
289    emit_detection_value(&mut buf, det, "");
290    for (i, line) in buf.lines().enumerate() {
291        if i == 0 {
292            let _ = writeln!(out, "{indent}- {line}");
293        } else {
294            let _ = writeln!(out, "{indent}  {line}");
295        }
296    }
297}
298
299/// Emit a single detection item (`field|mods: value` or a value list).
300fn emit_item(out: &mut String, item: &DetectionItem, indent: &str) {
301    let base = item.field.name.as_deref().unwrap_or(".");
302    let key = field_key(base, &item.field.modifiers);
303    // `re`, `cidr`, and `fieldref` values are raw strings (the parser reads them
304    // without wildcard interpretation), so they must be emitted verbatim rather
305    // than wildcard-escaped, or a regex like `ab.*c` would gain a stray `\`.
306    let raw = item
307        .field
308        .modifiers
309        .iter()
310        .any(|m| matches!(m, Modifier::Re | Modifier::Cidr | Modifier::FieldRef));
311    match item.values.as_slice() {
312        [single] => {
313            let _ = writeln!(out, "{indent}{key}: {}", value_token_ctx(single, raw));
314        }
315        values => {
316            let _ = writeln!(out, "{indent}{key}:");
317            for value in values {
318                let _ = writeln!(out, "{indent}{STEP}- {}", value_token_ctx(value, raw));
319            }
320        }
321    }
322}
323
324// =============================================================================
325// yaml_serde value emission (custom attributes)
326// =============================================================================
327
328fn emit_yaml_value(out: &mut String, key: &str, value: &yaml_serde::Value, indent: &str) {
329    match value {
330        yaml_serde::Value::Mapping(map) if !map.is_empty() => {
331            let _ = writeln!(out, "{indent}{key}:");
332            for (k, v) in map {
333                let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
334                emit_yaml_value(out, &child_key, v, &deeper(indent));
335            }
336        }
337        yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
338            let _ = writeln!(out, "{indent}{key}:");
339            for v in seq {
340                emit_yaml_seq_item(out, v, &deeper(indent));
341            }
342        }
343        scalar => {
344            let _ = writeln!(out, "{indent}{key}: {}", yaml_scalar(scalar));
345        }
346    }
347}
348
349/// Emit one YAML sequence item, including nested mappings and sequences.
350fn emit_yaml_seq_item(out: &mut String, value: &yaml_serde::Value, indent: &str) {
351    match value {
352        yaml_serde::Value::Mapping(map) if !map.is_empty() => {
353            let mut buf = String::new();
354            for (k, v) in map {
355                let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
356                emit_yaml_value(&mut buf, &child_key, v, "");
357            }
358            for (i, line) in buf.lines().enumerate() {
359                if i == 0 {
360                    let _ = writeln!(out, "{indent}- {line}");
361                } else {
362                    let _ = writeln!(out, "{indent}  {line}");
363                }
364            }
365        }
366        yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
367            let _ = writeln!(out, "{indent}-");
368            for v in seq {
369                emit_yaml_seq_item(out, v, &deeper(indent));
370            }
371        }
372        scalar => {
373            let _ = writeln!(out, "{indent}- {}", yaml_scalar(scalar));
374        }
375    }
376}
377
378fn yaml_scalar(value: &yaml_serde::Value) -> String {
379    match value {
380        yaml_serde::Value::Null => "null".to_string(),
381        yaml_serde::Value::Bool(b) => b.to_string(),
382        yaml_serde::Value::Number(n) => n.to_string(),
383        yaml_serde::Value::String(s) => scalar(s),
384        // Nested collections are handled by emit_yaml_value; an inline fallback
385        // keeps the emitter total for unexpected placements.
386        other => scalar(&format!("{other:?}")),
387    }
388}
389
390// =============================================================================
391// Scalars, keys, and value tokens
392// =============================================================================
393
394/// Render a [`SigmaValue`] as a YAML token.
395fn value_token(value: &SigmaValue) -> String {
396    value_token_ctx(value, false)
397}
398
399/// Render a [`SigmaValue`] as a YAML token. When `raw` is set the string is a
400/// raw value (a regex, CIDR, or field reference) and is emitted verbatim rather
401/// than with Sigma wildcard escaping.
402fn value_token_ctx(value: &SigmaValue, raw: bool) -> String {
403    match value {
404        SigmaValue::String(s) if raw => scalar(&s.as_plain().unwrap_or_else(|| s.original.clone())),
405        SigmaValue::String(s) => scalar(&sigma_string_source(s)),
406        SigmaValue::Integer(n) => n.to_string(),
407        SigmaValue::Float(f) => float_token(*f),
408        SigmaValue::Bool(b) => b.to_string(),
409        SigmaValue::Null => "null".to_string(),
410    }
411}
412
413/// Format a float so it re-parses as a float (never collapses `3.0` to `3`).
414fn float_token(f: f64) -> String {
415    let s = f.to_string();
416    if s.contains(['.', 'e', 'E']) || s.contains("inf") || s.contains("NaN") {
417        s
418    } else {
419        format!("{s}.0")
420    }
421}
422
423/// Reconstruct the Sigma source text of a [`SigmaString`], escaping literal
424/// wildcard and backslash characters so the value round-trips through the
425/// parser unchanged.
426fn sigma_string_source(value: &SigmaString) -> String {
427    let mut out = String::with_capacity(value.original.len());
428    for part in &value.parts {
429        match part {
430            StringPart::Plain(text) => push_escaped_literal(&mut out, text),
431            StringPart::Special(SpecialChar::WildcardMulti) => out.push('*'),
432            StringPart::Special(SpecialChar::WildcardSingle) => out.push('?'),
433        }
434    }
435    out
436}
437
438/// Escape a literal segment: `*`/`?` always gain a backslash; a run of
439/// backslashes is doubled only when it would otherwise bind to a following
440/// wildcard or end the value (keeping plain Windows paths readable).
441fn push_escaped_literal(out: &mut String, text: &str) {
442    let chars: Vec<char> = text.chars().collect();
443    let mut i = 0;
444    while i < chars.len() {
445        match chars[i] {
446            '*' => out.push_str("\\*"),
447            '?' => out.push_str("\\?"),
448            '\\' => {
449                let mut j = i;
450                while j < chars.len() && chars[j] == '\\' {
451                    j += 1;
452                }
453                let run = j - i;
454                let next = chars.get(j);
455                let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
456                for _ in 0..run {
457                    out.push_str(if must_escape { "\\\\" } else { "\\" });
458                }
459                i = j;
460                continue;
461            }
462            other => out.push(other),
463        }
464        i += 1;
465    }
466}
467
468/// Quote a value scalar in Sigma's single-quote convention unless it is a
469/// bare-safe token.
470fn scalar(s: &str) -> String {
471    if is_bare_safe(s) {
472        s.to_string()
473    } else {
474        quote(s)
475    }
476}
477
478/// Looser quoting for prose scalars (title, description, author): plain YAML
479/// permits internal spaces, so common values stay unquoted.
480fn scalar_prose(s: &str) -> String {
481    let bare = !s.is_empty()
482        && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
483        && !s.ends_with(' ')
484        && !s.contains(": ")
485        && !s.contains(" #")
486        && !s.contains('\n')
487        && s.chars().all(|c| {
488            c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')' | '/')
489        });
490    if bare { s.to_string() } else { quote(s) }
491}
492
493fn quote(s: &str) -> String {
494    format!("'{}'", s.replace('\'', "''"))
495}
496
497fn is_bare_safe(s: &str) -> bool {
498    !s.is_empty()
499        && s.chars()
500            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
501        && !s.starts_with('-')
502        && s.parse::<f64>().is_err()
503        && !matches!(
504            s.to_ascii_lowercase().as_str(),
505            "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~"
506        )
507}
508
509/// A mapping key (metadata key, custom-attribute key, or logsource custom
510/// field). Quoted only when it contains characters unsafe in a plain key.
511fn key_token(s: &str) -> String {
512    let safe = !s.is_empty()
513        && s.chars()
514            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
515    if safe { s.to_string() } else { quote(s) }
516}
517
518/// A detection field name used inside a key (bare identifiers, dotted paths,
519/// and array/index markers pass through; anything else is quoted).
520fn field_token(s: &str) -> String {
521    let safe = !s.is_empty()
522        && s.chars().all(|c| {
523            c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '[' | ']' | '@')
524        });
525    if safe { s.to_string() } else { quote(s) }
526}
527
528/// Build a detection key from a field name and its ordered modifiers.
529fn field_key(field: &str, modifiers: &[Modifier]) -> String {
530    let mut key = String::new();
531    for (i, part) in field.split('|').enumerate() {
532        if i > 0 {
533            key.push('|');
534        }
535        key.push_str(&field_token(part));
536    }
537    for modifier in modifiers {
538        key.push('|');
539        key.push_str(modifier_str(*modifier));
540    }
541    key
542}
543
544fn modifier_str(modifier: Modifier) -> &'static str {
545    match modifier {
546        Modifier::Contains => "contains",
547        Modifier::StartsWith => "startswith",
548        Modifier::EndsWith => "endswith",
549        Modifier::All => "all",
550        Modifier::Base64 => "base64",
551        Modifier::Base64Offset => "base64offset",
552        Modifier::Wide => "wide",
553        Modifier::Utf16be => "utf16be",
554        Modifier::Utf16 => "utf16",
555        Modifier::WindAsh => "windash",
556        Modifier::Re => "re",
557        Modifier::Cidr => "cidr",
558        Modifier::Cased => "cased",
559        Modifier::Exists => "exists",
560        Modifier::Expand => "expand",
561        Modifier::FieldRef => "fieldref",
562        Modifier::Gt => "gt",
563        Modifier::Gte => "gte",
564        Modifier::Lt => "lt",
565        Modifier::Lte => "lte",
566        Modifier::Neq => "neq",
567        Modifier::IgnoreCase => "i",
568        Modifier::Multiline => "m",
569        Modifier::DotAll => "s",
570        Modifier::Minute => "minute",
571        Modifier::Hour => "hour",
572        Modifier::Day => "day",
573        Modifier::Week => "week",
574        Modifier::Month => "month",
575        Modifier::Year => "year",
576    }
577}
578
579fn status_str(status: Status) -> &'static str {
580    match status {
581        Status::Stable => "stable",
582        Status::Test => "test",
583        Status::Experimental => "experimental",
584        Status::Deprecated => "deprecated",
585        Status::Unsupported => "unsupported",
586    }
587}
588
589fn relation_str(relation: RelationType) -> &'static str {
590    match relation {
591        RelationType::Correlation => "correlation",
592        RelationType::Derived => "derived",
593        RelationType::Obsolete => "obsolete",
594        RelationType::Merged => "merged",
595        RelationType::Renamed => "renamed",
596        RelationType::Similar => "similar",
597    }
598}
599
600fn array_str(quantifier: ArrayQuantifier) -> &'static str {
601    match quantifier {
602        ArrayQuantifier::Any => "any",
603        ArrayQuantifier::All => "all",
604        ArrayQuantifier::AllOrEmpty => "all_or_empty",
605        ArrayQuantifier::None => "none",
606    }
607}
608
609// =============================================================================
610// Small helpers
611// =============================================================================
612
613fn deeper(indent: &str) -> String {
614    format!("{indent}{STEP}")
615}
616
617fn sorted(map: &std::collections::HashMap<String, String>) -> Vec<(&str, &str)> {
618    let mut entries: Vec<(&str, &str)> =
619        map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
620    entries.sort_by(|a, b| a.0.cmp(b.0));
621    entries
622}
623
624fn sorted_named(map: &std::collections::HashMap<String, Detection>) -> Vec<(&str, &Detection)> {
625    let mut entries: BTreeMap<&str, &Detection> = BTreeMap::new();
626    for (k, v) in map {
627        entries.insert(k.as_str(), v);
628    }
629    entries.into_iter().collect()
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::parse_sigma_yaml;
636
637    /// Assert the emitter is a stable canonical form: `emit(parse(x))` re-parses
638    /// to one rule and re-emits to a byte-identical string. This is the correct
639    /// round-trip criterion; it does not depend on the raw source spelling a
640    /// [`SigmaString`] preserves in its `original` field.
641    fn assert_round_trips(yaml: &str) -> String {
642        let first = parse_sigma_yaml(yaml).expect("input parses");
643        assert_eq!(first.rules.len(), 1, "expected one input rule");
644        let emitted = emit_rule_yaml(&first.rules[0]);
645
646        let reparsed = parse_sigma_yaml(&emitted)
647            .unwrap_or_else(|e| panic!("emitted YAML must re-parse: {e}\n---\n{emitted}"));
648        assert_eq!(
649            reparsed.rules.len(),
650            1,
651            "expected one rule, got:\n{emitted}"
652        );
653
654        let reemitted = emit_rule_yaml(&reparsed.rules[0]);
655        assert_eq!(emitted, reemitted, "emit is not idempotent:\n{emitted}");
656        emitted
657    }
658
659    #[test]
660    fn round_trips_minimal_rule() {
661        assert_round_trips(
662            "title: Whoami\nlogsource:\n    product: windows\n    category: process_creation\ndetection:\n    selection:\n        CommandLine|contains: whoami\n    condition: selection\nlevel: medium\n",
663        );
664    }
665
666    #[test]
667    fn round_trips_modifiers_and_value_lists() {
668        assert_round_trips(
669            "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",
670        );
671    }
672
673    #[test]
674    fn round_trips_keywords_and_anyof() {
675        assert_round_trips(
676            "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",
677        );
678    }
679
680    #[test]
681    fn round_trips_metadata_and_selector_condition() {
682        assert_round_trips(
683            "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",
684        );
685    }
686
687    #[test]
688    fn escapes_wildcards_in_literal_values() {
689        // A literal asterisk in the value must survive as a literal, not a wildcard.
690        let yaml = "title: Escapes\nlogsource:\n    product: test\ndetection:\n    selection:\n        Field: 'a\\*b'\n    condition: selection\n";
691        let emitted = assert_round_trips(yaml);
692        assert!(
693            emitted.contains(r"a\*b"),
694            "expected escaped glob, got:\n{emitted}"
695        );
696    }
697
698    #[test]
699    fn empty_logsource_emits_a_mapping_that_reparses() {
700        let emitted = assert_round_trips(
701            "title: No Logsource\nlogsource: {}\ndetection:\n    selection:\n        Field: value\n    condition: selection\n",
702        );
703        assert!(emitted.contains("logsource: {}"), "{emitted}");
704    }
705
706    #[test]
707    fn round_trips_detection_exemplars() {
708        let emitted = assert_round_trips(
709            "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",
710        );
711        assert!(
712            emitted.contains("rsigma.exemplars:"),
713            "expected exemplars in emit:\n{emitted}"
714        );
715        assert!(
716            emitted.contains("whoami fires"),
717            "expected exemplar name:\n{emitted}"
718        );
719        let reparsed = parse_sigma_yaml(&emitted).unwrap();
720        let attrs = &reparsed.rules[0].custom_attributes;
721        let list = crate::exemplar::exemplars_from_attrs(
722            attrs,
723            crate::exemplar::ExemplarRuleKind::Detection,
724        )
725        .expect("emitted exemplars re-parse");
726        assert_eq!(list.len(), 2);
727        assert_eq!(list[0].expect, crate::exemplar::Expect::Match);
728        assert_eq!(list[1].expect, crate::exemplar::Expect::NoMatch);
729    }
730
731    #[test]
732    fn emit_preserves_correlation_shaped_exemplar_sequence() {
733        let emitted = assert_round_trips(
734            "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",
735        );
736        assert!(emitted.contains("User:"), "{emitted}");
737    }
738
739    #[test]
740    fn round_trips_array_object_scope_block() {
741        assert_round_trips(
742            "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",
743        );
744    }
745}