1use std::collections::HashMap;
11
12use base64::Engine as Base64Engine;
13use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
14use regex::Regex;
15
16use rsigma_parser::value::{SpecialChar, StringPart};
17use rsigma_parser::{
18 ConditionExpr, Detection, DetectionItem, Level, LogSource, Modifier, Quantifier,
19 SelectorPattern, SigmaRule, SigmaString, SigmaValue,
20};
21
22use crate::error::{EvalError, Result};
23use crate::event::Event;
24use crate::matcher::{CompiledMatcher, sigma_string_to_regex};
25use crate::result::{FieldMatch, MatchResult};
26
27#[derive(Debug, Clone)]
33pub struct CompiledRule {
34 pub title: String,
35 pub id: Option<String>,
36 pub level: Option<Level>,
37 pub tags: Vec<String>,
38 pub logsource: LogSource,
39 pub detections: HashMap<String, CompiledDetection>,
41 pub conditions: Vec<ConditionExpr>,
43 pub include_event: bool,
46}
47
48#[derive(Debug, Clone)]
50pub enum CompiledDetection {
51 AllOf(Vec<CompiledDetectionItem>),
53 AnyOf(Vec<CompiledDetection>),
55 Keywords(CompiledMatcher),
57}
58
59#[derive(Debug, Clone)]
61pub struct CompiledDetectionItem {
62 pub field: Option<String>,
64 pub matcher: CompiledMatcher,
66 pub exists: Option<bool>,
68}
69
70#[derive(Clone, Copy)]
76struct ModCtx {
77 contains: bool,
78 startswith: bool,
79 endswith: bool,
80 all: bool,
81 base64: bool,
82 base64offset: bool,
83 wide: bool,
84 utf16be: bool,
85 utf16: bool,
86 windash: bool,
87 re: bool,
88 cidr: bool,
89 cased: bool,
90 exists: bool,
91 fieldref: bool,
92 gt: bool,
93 gte: bool,
94 lt: bool,
95 lte: bool,
96 neq: bool,
97 ignore_case: bool,
98 multiline: bool,
99 dotall: bool,
100 expand: bool,
101 timestamp_part: Option<crate::matcher::TimePart>,
102}
103
104impl ModCtx {
105 fn from_modifiers(modifiers: &[Modifier]) -> Self {
106 let mut ctx = ModCtx {
107 contains: false,
108 startswith: false,
109 endswith: false,
110 all: false,
111 base64: false,
112 base64offset: false,
113 wide: false,
114 utf16be: false,
115 utf16: false,
116 windash: false,
117 re: false,
118 cidr: false,
119 cased: false,
120 exists: false,
121 fieldref: false,
122 gt: false,
123 gte: false,
124 lt: false,
125 lte: false,
126 neq: false,
127 ignore_case: false,
128 multiline: false,
129 dotall: false,
130 expand: false,
131 timestamp_part: None,
132 };
133 for m in modifiers {
134 match m {
135 Modifier::Contains => ctx.contains = true,
136 Modifier::StartsWith => ctx.startswith = true,
137 Modifier::EndsWith => ctx.endswith = true,
138 Modifier::All => ctx.all = true,
139 Modifier::Base64 => ctx.base64 = true,
140 Modifier::Base64Offset => ctx.base64offset = true,
141 Modifier::Wide => ctx.wide = true,
142 Modifier::Utf16be => ctx.utf16be = true,
143 Modifier::Utf16 => ctx.utf16 = true,
144 Modifier::WindAsh => ctx.windash = true,
145 Modifier::Re => ctx.re = true,
146 Modifier::Cidr => ctx.cidr = true,
147 Modifier::Cased => ctx.cased = true,
148 Modifier::Exists => ctx.exists = true,
149 Modifier::FieldRef => ctx.fieldref = true,
150 Modifier::Gt => ctx.gt = true,
151 Modifier::Gte => ctx.gte = true,
152 Modifier::Lt => ctx.lt = true,
153 Modifier::Lte => ctx.lte = true,
154 Modifier::Neq => ctx.neq = true,
155 Modifier::IgnoreCase => ctx.ignore_case = true,
156 Modifier::Multiline => ctx.multiline = true,
157 Modifier::DotAll => ctx.dotall = true,
158 Modifier::Expand => ctx.expand = true,
159 Modifier::Hour => ctx.timestamp_part = Some(crate::matcher::TimePart::Hour),
160 Modifier::Day => ctx.timestamp_part = Some(crate::matcher::TimePart::Day),
161 Modifier::Week => ctx.timestamp_part = Some(crate::matcher::TimePart::Week),
162 Modifier::Month => ctx.timestamp_part = Some(crate::matcher::TimePart::Month),
163 Modifier::Year => ctx.timestamp_part = Some(crate::matcher::TimePart::Year),
164 Modifier::Minute => ctx.timestamp_part = Some(crate::matcher::TimePart::Minute),
165 }
166 }
167 ctx
168 }
169
170 fn is_case_insensitive(&self) -> bool {
173 !self.cased
174 }
175
176 fn has_numeric_comparison(&self) -> bool {
178 self.gt || self.gte || self.lt || self.lte
179 }
180
181 fn has_neq(&self) -> bool {
183 self.neq
184 }
185}
186
187pub fn compile_rule(rule: &SigmaRule) -> Result<CompiledRule> {
193 let mut detections = HashMap::new();
194 for (name, detection) in &rule.detection.named {
195 detections.insert(name.clone(), compile_detection(detection)?);
196 }
197
198 for condition in &rule.detection.conditions {
199 validate_condition_refs(condition, &detections)?;
200 }
201
202 let include_event = rule
203 .custom_attributes
204 .get("rsigma.include_event")
205 .is_some_and(|v| v == "true");
206
207 Ok(CompiledRule {
208 title: rule.title.clone(),
209 id: rule.id.clone(),
210 level: rule.level,
211 tags: rule.tags.clone(),
212 logsource: rule.logsource.clone(),
213 detections,
214 conditions: rule.detection.conditions.clone(),
215 include_event,
216 })
217}
218
219fn validate_condition_refs(
223 expr: &ConditionExpr,
224 detections: &HashMap<String, CompiledDetection>,
225) -> Result<()> {
226 match expr {
227 ConditionExpr::Identifier(name) => {
228 if !detections.contains_key(name) {
229 return Err(EvalError::UnknownDetection(name.clone()));
230 }
231 Ok(())
232 }
233 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
234 for e in exprs {
235 validate_condition_refs(e, detections)?;
236 }
237 Ok(())
238 }
239 ConditionExpr::Not(inner) => validate_condition_refs(inner, detections),
240 ConditionExpr::Selector { .. } => Ok(()),
241 }
242}
243
244pub fn evaluate_rule(rule: &CompiledRule, event: &Event) -> Option<MatchResult> {
246 for condition in &rule.conditions {
248 let mut matched_selections = Vec::new();
249 if eval_condition(condition, &rule.detections, event, &mut matched_selections) {
250 let matched_fields =
252 collect_field_matches(&matched_selections, &rule.detections, event);
253
254 let event_data = if rule.include_event {
255 Some(event.as_value().clone())
256 } else {
257 None
258 };
259
260 return Some(MatchResult {
261 rule_title: rule.title.clone(),
262 rule_id: rule.id.clone(),
263 level: rule.level,
264 tags: rule.tags.clone(),
265 matched_selections,
266 matched_fields,
267 event: event_data,
268 });
269 }
270 }
271 None
272}
273
274pub fn compile_detection(detection: &Detection) -> Result<CompiledDetection> {
283 match detection {
284 Detection::AllOf(items) => {
285 if items.is_empty() {
286 return Err(EvalError::InvalidModifiers(
287 "AllOf detection must not be empty (vacuous truth)".into(),
288 ));
289 }
290 let compiled: Result<Vec<_>> = items.iter().map(compile_detection_item).collect();
291 Ok(CompiledDetection::AllOf(compiled?))
292 }
293 Detection::AnyOf(dets) => {
294 if dets.is_empty() {
295 return Err(EvalError::InvalidModifiers(
296 "AnyOf detection must not be empty (would never match)".into(),
297 ));
298 }
299 let compiled: Result<Vec<_>> = dets.iter().map(compile_detection).collect();
300 Ok(CompiledDetection::AnyOf(compiled?))
301 }
302 Detection::Keywords(values) => {
303 let ci = true; let matchers: Vec<CompiledMatcher> = values
305 .iter()
306 .map(|v| compile_value_default(v, ci))
307 .collect::<Result<Vec<_>>>()?;
308 let matcher = if matchers.len() == 1 {
309 matchers
311 .into_iter()
312 .next()
313 .unwrap_or(CompiledMatcher::AnyOf(vec![]))
314 } else {
315 CompiledMatcher::AnyOf(matchers)
316 };
317 Ok(CompiledDetection::Keywords(matcher))
318 }
319 }
320}
321
322fn compile_detection_item(item: &DetectionItem) -> Result<CompiledDetectionItem> {
323 let ctx = ModCtx::from_modifiers(&item.field.modifiers);
324
325 if ctx.exists {
327 let expect = match item.values.first() {
328 Some(SigmaValue::Bool(b)) => *b,
329 Some(SigmaValue::String(s)) => match s.as_plain().as_deref() {
330 Some("true") | Some("yes") => true,
331 Some("false") | Some("no") => false,
332 _ => true,
333 },
334 _ => true,
335 };
336 return Ok(CompiledDetectionItem {
337 field: item.field.name.clone(),
338 matcher: CompiledMatcher::Exists(expect),
339 exists: Some(expect),
340 });
341 }
342
343 if ctx.all && item.values.len() <= 1 {
345 return Err(EvalError::InvalidModifiers(
346 "|all modifier requires more than one value".to_string(),
347 ));
348 }
349
350 let matchers: Result<Vec<CompiledMatcher>> =
352 item.values.iter().map(|v| compile_value(v, &ctx)).collect();
353 let matchers = matchers?;
354
355 let combined = if matchers.len() == 1 {
357 matchers
359 .into_iter()
360 .next()
361 .unwrap_or(CompiledMatcher::AnyOf(vec![]))
362 } else if ctx.all {
363 CompiledMatcher::AllOf(matchers)
364 } else {
365 CompiledMatcher::AnyOf(matchers)
366 };
367
368 Ok(CompiledDetectionItem {
369 field: item.field.name.clone(),
370 matcher: combined,
371 exists: None,
372 })
373}
374
375fn compile_value(value: &SigmaValue, ctx: &ModCtx) -> Result<CompiledMatcher> {
381 let ci = ctx.is_case_insensitive();
382
383 if ctx.expand {
387 let plain = value_to_plain_string(value)?;
388 let template = crate::matcher::parse_expand_template(&plain);
389 return Ok(CompiledMatcher::Expand {
390 template,
391 case_insensitive: ci,
392 });
393 }
394
395 if let Some(part) = ctx.timestamp_part {
397 let inner = match value {
400 SigmaValue::Integer(n) => CompiledMatcher::NumericEq(*n as f64),
401 SigmaValue::Float(n) => CompiledMatcher::NumericEq(*n),
402 SigmaValue::String(s) => {
403 let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
404 let n: f64 = plain.parse().map_err(|_| {
405 EvalError::IncompatibleValue(format!(
406 "timestamp part modifier requires numeric value, got: {plain}"
407 ))
408 })?;
409 CompiledMatcher::NumericEq(n)
410 }
411 _ => {
412 return Err(EvalError::IncompatibleValue(
413 "timestamp part modifier requires numeric value".into(),
414 ));
415 }
416 };
417 return Ok(CompiledMatcher::TimestampPart {
418 part,
419 inner: Box::new(inner),
420 });
421 }
422
423 if ctx.fieldref {
425 let field_name = value_to_plain_string(value)?;
426 return Ok(CompiledMatcher::FieldRef {
427 field: field_name,
428 case_insensitive: ci,
429 });
430 }
431
432 if ctx.re {
436 let pattern = value_to_plain_string(value)?;
437 let regex = build_regex(&pattern, ctx.ignore_case, ctx.multiline, ctx.dotall)?;
438 return Ok(CompiledMatcher::Regex(regex));
439 }
440
441 if ctx.cidr {
443 let cidr_str = value_to_plain_string(value)?;
444 let net: ipnet::IpNet = cidr_str
445 .parse()
446 .map_err(|e: ipnet::AddrParseError| EvalError::InvalidCidr(e))?;
447 return Ok(CompiledMatcher::Cidr(net));
448 }
449
450 if ctx.has_numeric_comparison() {
452 let n = value_to_f64(value)?;
453 if ctx.gt {
454 return Ok(CompiledMatcher::NumericGt(n));
455 }
456 if ctx.gte {
457 return Ok(CompiledMatcher::NumericGte(n));
458 }
459 if ctx.lt {
460 return Ok(CompiledMatcher::NumericLt(n));
461 }
462 if ctx.lte {
463 return Ok(CompiledMatcher::NumericLte(n));
464 }
465 }
466
467 if ctx.has_neq() {
469 let mut inner_ctx = ModCtx { ..*ctx };
471 inner_ctx.neq = false;
472 let inner = compile_value(value, &inner_ctx)?;
473 return Ok(CompiledMatcher::Not(Box::new(inner)));
474 }
475
476 match value {
478 SigmaValue::Integer(n) => {
479 if ctx.contains || ctx.startswith || ctx.endswith {
480 return compile_string_value(&n.to_string(), ctx);
482 }
483 return Ok(CompiledMatcher::NumericEq(*n as f64));
484 }
485 SigmaValue::Float(n) => {
486 if ctx.contains || ctx.startswith || ctx.endswith {
487 return compile_string_value(&n.to_string(), ctx);
488 }
489 return Ok(CompiledMatcher::NumericEq(*n));
490 }
491 SigmaValue::Bool(b) => return Ok(CompiledMatcher::BoolEq(*b)),
492 SigmaValue::Null => return Ok(CompiledMatcher::Null),
493 SigmaValue::String(_) => {} }
495
496 let sigma_str = match value {
498 SigmaValue::String(s) => s,
499 _ => unreachable!(),
500 };
501
502 let mut bytes = sigma_string_to_bytes(sigma_str);
504
505 if ctx.wide {
507 bytes = to_utf16le_bytes(&bytes);
508 }
509
510 if ctx.utf16be {
512 bytes = to_utf16be_bytes(&bytes);
513 }
514
515 if ctx.utf16 {
517 bytes = to_utf16_bom_bytes(&bytes);
518 }
519
520 if ctx.base64 {
522 let encoded = BASE64_STANDARD.encode(&bytes);
523 return compile_string_value(&encoded, ctx);
524 }
525
526 if ctx.base64offset {
528 let patterns = base64_offset_patterns(&bytes);
529 let matchers: Vec<CompiledMatcher> = patterns
530 .into_iter()
531 .map(|p| {
532 CompiledMatcher::Contains {
534 value: if ci { p.to_lowercase() } else { p },
535 case_insensitive: ci,
536 }
537 })
538 .collect();
539 return Ok(CompiledMatcher::AnyOf(matchers));
540 }
541
542 if ctx.windash {
544 let plain = sigma_str
545 .as_plain()
546 .unwrap_or_else(|| sigma_str.original.clone());
547 let variants = expand_windash(&plain)?;
548 let matchers: Result<Vec<CompiledMatcher>> = variants
549 .into_iter()
550 .map(|v| compile_string_value(&v, ctx))
551 .collect();
552 return Ok(CompiledMatcher::AnyOf(matchers?));
553 }
554
555 compile_sigma_string(sigma_str, ctx)
557}
558
559fn compile_sigma_string(sigma_str: &SigmaString, ctx: &ModCtx) -> Result<CompiledMatcher> {
561 let ci = ctx.is_case_insensitive();
562
563 if sigma_str.is_plain() {
565 let plain = sigma_str.as_plain().unwrap_or_default();
566 return compile_string_value(&plain, ctx);
567 }
568
569 let mut pattern = String::new();
574 if ci {
575 pattern.push_str("(?i)");
576 }
577
578 if !ctx.contains && !ctx.startswith {
579 pattern.push('^');
580 }
581
582 for part in &sigma_str.parts {
583 match part {
584 StringPart::Plain(text) => {
585 pattern.push_str(®ex::escape(text));
586 }
587 StringPart::Special(SpecialChar::WildcardMulti) => {
588 pattern.push_str(".*");
589 }
590 StringPart::Special(SpecialChar::WildcardSingle) => {
591 pattern.push('.');
592 }
593 }
594 }
595
596 if !ctx.contains && !ctx.endswith {
597 pattern.push('$');
598 }
599
600 let regex = Regex::new(&pattern).map_err(EvalError::InvalidRegex)?;
601 Ok(CompiledMatcher::Regex(regex))
602}
603
604fn compile_string_value(plain: &str, ctx: &ModCtx) -> Result<CompiledMatcher> {
606 let ci = ctx.is_case_insensitive();
607
608 if ctx.contains {
609 Ok(CompiledMatcher::Contains {
610 value: if ci {
611 plain.to_lowercase()
612 } else {
613 plain.to_string()
614 },
615 case_insensitive: ci,
616 })
617 } else if ctx.startswith {
618 Ok(CompiledMatcher::StartsWith {
619 value: if ci {
620 plain.to_lowercase()
621 } else {
622 plain.to_string()
623 },
624 case_insensitive: ci,
625 })
626 } else if ctx.endswith {
627 Ok(CompiledMatcher::EndsWith {
628 value: if ci {
629 plain.to_lowercase()
630 } else {
631 plain.to_string()
632 },
633 case_insensitive: ci,
634 })
635 } else {
636 Ok(CompiledMatcher::Exact {
637 value: if ci {
638 plain.to_lowercase()
639 } else {
640 plain.to_string()
641 },
642 case_insensitive: ci,
643 })
644 }
645}
646
647fn compile_value_default(value: &SigmaValue, case_insensitive: bool) -> Result<CompiledMatcher> {
649 match value {
650 SigmaValue::String(s) => {
651 if s.is_plain() {
652 let plain = s.as_plain().unwrap_or_default();
653 Ok(CompiledMatcher::Contains {
654 value: if case_insensitive {
655 plain.to_lowercase()
656 } else {
657 plain
658 },
659 case_insensitive,
660 })
661 } else {
662 let pattern = sigma_string_to_regex(&s.parts, case_insensitive);
664 let regex = Regex::new(&pattern).map_err(EvalError::InvalidRegex)?;
665 Ok(CompiledMatcher::Regex(regex))
666 }
667 }
668 SigmaValue::Integer(n) => Ok(CompiledMatcher::NumericEq(*n as f64)),
669 SigmaValue::Float(n) => Ok(CompiledMatcher::NumericEq(*n)),
670 SigmaValue::Bool(b) => Ok(CompiledMatcher::BoolEq(*b)),
671 SigmaValue::Null => Ok(CompiledMatcher::Null),
672 }
673}
674
675pub fn eval_condition(
684 expr: &ConditionExpr,
685 detections: &HashMap<String, CompiledDetection>,
686 event: &Event,
687 matched_selections: &mut Vec<String>,
688) -> bool {
689 match expr {
690 ConditionExpr::Identifier(name) => {
691 if let Some(det) = detections.get(name) {
692 let result = eval_detection(det, event);
693 if result {
694 matched_selections.push(name.clone());
695 }
696 result
697 } else {
698 false
699 }
700 }
701
702 ConditionExpr::And(exprs) => exprs
703 .iter()
704 .all(|e| eval_condition(e, detections, event, matched_selections)),
705
706 ConditionExpr::Or(exprs) => exprs
707 .iter()
708 .any(|e| eval_condition(e, detections, event, matched_selections)),
709
710 ConditionExpr::Not(inner) => !eval_condition(inner, detections, event, matched_selections),
711
712 ConditionExpr::Selector {
713 quantifier,
714 pattern,
715 } => {
716 let matching_names: Vec<&String> = match pattern {
717 SelectorPattern::Them => detections
718 .keys()
719 .filter(|name| !name.starts_with('_'))
720 .collect(),
721 SelectorPattern::Pattern(pat) => detections
722 .keys()
723 .filter(|name| pattern_matches(pat, name))
724 .collect(),
725 };
726
727 let mut match_count = 0u64;
728 for name in &matching_names {
729 if let Some(det) = detections.get(*name)
730 && eval_detection(det, event)
731 {
732 match_count += 1;
733 matched_selections.push((*name).clone());
734 }
735 }
736
737 match quantifier {
738 Quantifier::Any => match_count >= 1,
739 Quantifier::All => match_count == matching_names.len() as u64,
740 Quantifier::Count(n) => match_count >= *n,
741 }
742 }
743 }
744}
745
746fn eval_detection(detection: &CompiledDetection, event: &Event) -> bool {
748 match detection {
749 CompiledDetection::AllOf(items) => {
750 items.iter().all(|item| eval_detection_item(item, event))
751 }
752 CompiledDetection::AnyOf(dets) => dets.iter().any(|d| eval_detection(d, event)),
753 CompiledDetection::Keywords(matcher) => matcher.matches_keyword(event),
754 }
755}
756
757fn eval_detection_item(item: &CompiledDetectionItem, event: &Event) -> bool {
759 if let Some(expect_exists) = item.exists {
761 if let Some(field) = &item.field {
762 let exists = event.get_field(field).is_some_and(|v| !v.is_null());
763 return exists == expect_exists;
764 }
765 return !expect_exists; }
767
768 match &item.field {
769 Some(field_name) => {
770 if let Some(value) = event.get_field(field_name) {
772 item.matcher.matches(value, event)
773 } else {
774 matches!(item.matcher, CompiledMatcher::Null)
776 }
777 }
778 None => {
779 item.matcher.matches_keyword(event)
781 }
782 }
783}
784
785fn collect_field_matches(
787 selection_names: &[String],
788 detections: &HashMap<String, CompiledDetection>,
789 event: &Event,
790) -> Vec<FieldMatch> {
791 let mut matches = Vec::new();
792 for name in selection_names {
793 if let Some(det) = detections.get(name) {
794 collect_detection_fields(det, event, &mut matches);
795 }
796 }
797 matches
798}
799
800fn collect_detection_fields(
801 detection: &CompiledDetection,
802 event: &Event,
803 out: &mut Vec<FieldMatch>,
804) {
805 match detection {
806 CompiledDetection::AllOf(items) => {
807 for item in items {
808 if let Some(field_name) = &item.field
809 && let Some(value) = event.get_field(field_name)
810 && item.matcher.matches(value, event)
811 {
812 out.push(FieldMatch {
813 field: field_name.clone(),
814 value: value.clone(),
815 });
816 }
817 }
818 }
819 CompiledDetection::AnyOf(dets) => {
820 for d in dets {
821 if eval_detection(d, event) {
822 collect_detection_fields(d, event, out);
823 }
824 }
825 }
826 CompiledDetection::Keywords(_) => {
827 }
829 }
830}
831
832fn pattern_matches(pattern: &str, name: &str) -> bool {
838 if pattern == "*" {
839 return true;
840 }
841 if let Some(prefix) = pattern.strip_suffix('*') {
842 return name.starts_with(prefix);
843 }
844 if let Some(suffix) = pattern.strip_prefix('*') {
845 return name.ends_with(suffix);
846 }
847 pattern == name
848}
849
850fn value_to_plain_string(value: &SigmaValue) -> Result<String> {
856 match value {
857 SigmaValue::String(s) => Ok(s.as_plain().unwrap_or_else(|| s.original.clone())),
858 SigmaValue::Integer(n) => Ok(n.to_string()),
859 SigmaValue::Float(n) => Ok(n.to_string()),
860 SigmaValue::Bool(b) => Ok(b.to_string()),
861 SigmaValue::Null => Err(EvalError::IncompatibleValue(
862 "null value for string modifier".into(),
863 )),
864 }
865}
866
867fn value_to_f64(value: &SigmaValue) -> Result<f64> {
869 match value {
870 SigmaValue::Integer(n) => Ok(*n as f64),
871 SigmaValue::Float(n) => Ok(*n),
872 SigmaValue::String(s) => {
873 let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
874 plain
875 .parse::<f64>()
876 .map_err(|_| EvalError::ExpectedNumeric(plain))
877 }
878 _ => Err(EvalError::ExpectedNumeric(format!("{value:?}"))),
879 }
880}
881
882fn sigma_string_to_bytes(s: &SigmaString) -> Vec<u8> {
884 let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
885 plain.into_bytes()
886}
887
888fn to_utf16le_bytes(bytes: &[u8]) -> Vec<u8> {
894 let s = String::from_utf8_lossy(bytes);
895 let mut wide = Vec::with_capacity(s.len() * 2);
896 for c in s.chars() {
897 let mut buf = [0u16; 2];
898 let encoded = c.encode_utf16(&mut buf);
899 for u in encoded {
900 wide.extend_from_slice(&u.to_le_bytes());
901 }
902 }
903 wide
904}
905
906fn to_utf16be_bytes(bytes: &[u8]) -> Vec<u8> {
908 let s = String::from_utf8_lossy(bytes);
909 let mut wide = Vec::with_capacity(s.len() * 2);
910 for c in s.chars() {
911 let mut buf = [0u16; 2];
912 let encoded = c.encode_utf16(&mut buf);
913 for u in encoded {
914 wide.extend_from_slice(&u.to_be_bytes());
915 }
916 }
917 wide
918}
919
920fn to_utf16_bom_bytes(bytes: &[u8]) -> Vec<u8> {
922 let mut result = vec![0xFF, 0xFE]; result.extend_from_slice(&to_utf16le_bytes(bytes));
924 result
925}
926
927fn base64_offset_patterns(value: &[u8]) -> Vec<String> {
933 let mut patterns = Vec::with_capacity(3);
934
935 for offset in 0..3usize {
936 let mut padded = vec![0u8; offset];
937 padded.extend_from_slice(value);
938
939 let encoded = BASE64_STANDARD.encode(&padded);
940
941 let start = (offset * 4).div_ceil(3);
943 let trimmed = encoded.trim_end_matches('=');
945 let end = trimmed.len();
946
947 if start < end {
948 patterns.push(trimmed[start..end].to_string());
949 }
950 }
951
952 patterns
953}
954
955fn build_regex(
957 pattern: &str,
958 case_insensitive: bool,
959 multiline: bool,
960 dotall: bool,
961) -> Result<Regex> {
962 let mut flags = String::new();
963 if case_insensitive {
964 flags.push('i');
965 }
966 if multiline {
967 flags.push('m');
968 }
969 if dotall {
970 flags.push('s');
971 }
972
973 let full_pattern = if flags.is_empty() {
974 pattern.to_string()
975 } else {
976 format!("(?{flags}){pattern}")
977 };
978
979 Regex::new(&full_pattern).map_err(EvalError::InvalidRegex)
980}
981
982const WINDASH_CHARS: [char; 5] = ['-', '/', '\u{2013}', '\u{2014}', '\u{2015}'];
985
986const MAX_WINDASH_DASHES: usize = 8;
989
990fn expand_windash(input: &str) -> Result<Vec<String>> {
993 let dash_positions: Vec<usize> = input
995 .char_indices()
996 .filter(|(_, c)| *c == '-')
997 .map(|(i, _)| i)
998 .collect();
999
1000 if dash_positions.is_empty() {
1001 return Ok(vec![input.to_string()]);
1002 }
1003
1004 let n = dash_positions.len();
1005 if n > MAX_WINDASH_DASHES {
1006 return Err(EvalError::InvalidModifiers(format!(
1007 "windash modifier: value contains {n} dashes, max is {MAX_WINDASH_DASHES} \
1008 (would generate {} variants)",
1009 5u64.saturating_pow(n as u32)
1010 )));
1011 }
1012
1013 let total = WINDASH_CHARS.len().pow(n as u32);
1015 let mut variants = Vec::with_capacity(total);
1016
1017 for combo in 0..total {
1018 let mut variant = input.to_string();
1019 let mut idx = combo;
1020 for &pos in dash_positions.iter().rev() {
1022 let replacement = WINDASH_CHARS[idx % WINDASH_CHARS.len()];
1023 variant.replace_range(pos..pos + 1, &replacement.to_string());
1024 idx /= WINDASH_CHARS.len();
1025 }
1026 variants.push(variant);
1027 }
1028
1029 Ok(variants)
1030}
1031
1032#[cfg(test)]
1037mod tests {
1038 use super::*;
1039 use rsigma_parser::FieldSpec;
1040 use serde_json::json;
1041
1042 fn make_field_spec(name: &str, modifiers: &[Modifier]) -> FieldSpec {
1043 FieldSpec::new(Some(name.to_string()), modifiers.to_vec())
1044 }
1045
1046 fn make_item(name: &str, modifiers: &[Modifier], values: Vec<SigmaValue>) -> DetectionItem {
1047 DetectionItem {
1048 field: make_field_spec(name, modifiers),
1049 values,
1050 }
1051 }
1052
1053 #[test]
1054 fn test_compile_exact_match() {
1055 let item = make_item(
1056 "CommandLine",
1057 &[],
1058 vec![SigmaValue::String(SigmaString::new("whoami"))],
1059 );
1060 let compiled = compile_detection_item(&item).unwrap();
1061 assert_eq!(compiled.field, Some("CommandLine".into()));
1062
1063 let ev = json!({"CommandLine": "whoami"});
1064 let event = Event::from_value(&ev);
1065 assert!(eval_detection_item(&compiled, &event));
1066
1067 let ev2 = json!({"CommandLine": "WHOAMI"});
1068 let event2 = Event::from_value(&ev2);
1069 assert!(eval_detection_item(&compiled, &event2)); }
1071
1072 #[test]
1073 fn test_compile_contains() {
1074 let item = make_item(
1075 "CommandLine",
1076 &[Modifier::Contains],
1077 vec![SigmaValue::String(SigmaString::new("whoami"))],
1078 );
1079 let compiled = compile_detection_item(&item).unwrap();
1080
1081 let ev = json!({"CommandLine": "cmd /c whoami /all"});
1082 let event = Event::from_value(&ev);
1083 assert!(eval_detection_item(&compiled, &event));
1084
1085 let ev2 = json!({"CommandLine": "ipconfig"});
1086 let event2 = Event::from_value(&ev2);
1087 assert!(!eval_detection_item(&compiled, &event2));
1088 }
1089
1090 #[test]
1091 fn test_compile_endswith() {
1092 let item = make_item(
1093 "Image",
1094 &[Modifier::EndsWith],
1095 vec![SigmaValue::String(SigmaString::new(".exe"))],
1096 );
1097 let compiled = compile_detection_item(&item).unwrap();
1098
1099 let ev = json!({"Image": "C:\\Windows\\cmd.exe"});
1100 let event = Event::from_value(&ev);
1101 assert!(eval_detection_item(&compiled, &event));
1102
1103 let ev2 = json!({"Image": "C:\\Windows\\cmd.bat"});
1104 let event2 = Event::from_value(&ev2);
1105 assert!(!eval_detection_item(&compiled, &event2));
1106 }
1107
1108 #[test]
1109 fn test_compile_contains_all() {
1110 let item = make_item(
1111 "CommandLine",
1112 &[Modifier::Contains, Modifier::All],
1113 vec![
1114 SigmaValue::String(SigmaString::new("net")),
1115 SigmaValue::String(SigmaString::new("user")),
1116 ],
1117 );
1118 let compiled = compile_detection_item(&item).unwrap();
1119
1120 let ev = json!({"CommandLine": "net user admin"});
1121 let event = Event::from_value(&ev);
1122 assert!(eval_detection_item(&compiled, &event));
1123
1124 let ev2 = json!({"CommandLine": "net localgroup"});
1125 let event2 = Event::from_value(&ev2);
1126 assert!(!eval_detection_item(&compiled, &event2)); }
1128
1129 #[test]
1130 fn test_all_modifier_single_value_rejected() {
1131 let item = make_item(
1132 "CommandLine",
1133 &[Modifier::Contains, Modifier::All],
1134 vec![SigmaValue::String(SigmaString::new("net"))],
1135 );
1136 let result = compile_detection_item(&item);
1137 assert!(result.is_err());
1138 let err = result.unwrap_err().to_string();
1139 assert!(err.contains("|all modifier requires more than one value"));
1140 }
1141
1142 #[test]
1143 fn test_all_modifier_empty_values_rejected() {
1144 let item = make_item("CommandLine", &[Modifier::Contains, Modifier::All], vec![]);
1145 let result = compile_detection_item(&item);
1146 assert!(result.is_err());
1147 }
1148
1149 #[test]
1150 fn test_all_modifier_multiple_values_accepted() {
1151 let item = make_item(
1153 "CommandLine",
1154 &[Modifier::Contains, Modifier::All],
1155 vec![
1156 SigmaValue::String(SigmaString::new("net")),
1157 SigmaValue::String(SigmaString::new("user")),
1158 ],
1159 );
1160 assert!(compile_detection_item(&item).is_ok());
1161 }
1162
1163 #[test]
1164 fn test_compile_regex() {
1165 let item = make_item(
1166 "CommandLine",
1167 &[Modifier::Re],
1168 vec![SigmaValue::String(SigmaString::from_raw(r"cmd\.exe.*/c"))],
1169 );
1170 let compiled = compile_detection_item(&item).unwrap();
1171
1172 let ev = json!({"CommandLine": "cmd.exe /c whoami"});
1173 let event = Event::from_value(&ev);
1174 assert!(eval_detection_item(&compiled, &event));
1175 }
1176
1177 #[test]
1178 fn test_regex_case_sensitive_by_default() {
1179 let item = make_item(
1181 "User",
1182 &[Modifier::Re],
1183 vec![SigmaValue::String(SigmaString::from_raw("Admin"))],
1184 );
1185 let compiled = compile_detection_item(&item).unwrap();
1186
1187 let ev_match = json!({"User": "Admin"});
1188 assert!(eval_detection_item(
1189 &compiled,
1190 &Event::from_value(&ev_match)
1191 ));
1192
1193 let ev_no_match = json!({"User": "admin"});
1194 assert!(!eval_detection_item(
1195 &compiled,
1196 &Event::from_value(&ev_no_match)
1197 ));
1198 }
1199
1200 #[test]
1201 fn test_regex_case_insensitive_with_i_modifier() {
1202 let item = make_item(
1204 "User",
1205 &[Modifier::Re, Modifier::IgnoreCase],
1206 vec![SigmaValue::String(SigmaString::from_raw("Admin"))],
1207 );
1208 let compiled = compile_detection_item(&item).unwrap();
1209
1210 let ev_exact = json!({"User": "Admin"});
1211 assert!(eval_detection_item(
1212 &compiled,
1213 &Event::from_value(&ev_exact)
1214 ));
1215
1216 let ev_lower = json!({"User": "admin"});
1217 assert!(eval_detection_item(
1218 &compiled,
1219 &Event::from_value(&ev_lower)
1220 ));
1221 }
1222
1223 #[test]
1224 fn test_compile_cidr() {
1225 let item = make_item(
1226 "SourceIP",
1227 &[Modifier::Cidr],
1228 vec![SigmaValue::String(SigmaString::new("10.0.0.0/8"))],
1229 );
1230 let compiled = compile_detection_item(&item).unwrap();
1231
1232 let ev = json!({"SourceIP": "10.1.2.3"});
1233 let event = Event::from_value(&ev);
1234 assert!(eval_detection_item(&compiled, &event));
1235
1236 let ev2 = json!({"SourceIP": "192.168.1.1"});
1237 let event2 = Event::from_value(&ev2);
1238 assert!(!eval_detection_item(&compiled, &event2));
1239 }
1240
1241 #[test]
1242 fn test_compile_exists() {
1243 let item = make_item(
1244 "SomeField",
1245 &[Modifier::Exists],
1246 vec![SigmaValue::Bool(true)],
1247 );
1248 let compiled = compile_detection_item(&item).unwrap();
1249
1250 let ev = json!({"SomeField": "value"});
1251 let event = Event::from_value(&ev);
1252 assert!(eval_detection_item(&compiled, &event));
1253
1254 let ev2 = json!({"OtherField": "value"});
1255 let event2 = Event::from_value(&ev2);
1256 assert!(!eval_detection_item(&compiled, &event2));
1257 }
1258
1259 #[test]
1260 fn test_compile_wildcard() {
1261 let item = make_item(
1262 "Image",
1263 &[],
1264 vec![SigmaValue::String(SigmaString::new(r"*\cmd.exe"))],
1265 );
1266 let compiled = compile_detection_item(&item).unwrap();
1267
1268 let ev = json!({"Image": "C:\\Windows\\System32\\cmd.exe"});
1269 let event = Event::from_value(&ev);
1270 assert!(eval_detection_item(&compiled, &event));
1271
1272 let ev2 = json!({"Image": "C:\\Windows\\powershell.exe"});
1273 let event2 = Event::from_value(&ev2);
1274 assert!(!eval_detection_item(&compiled, &event2));
1275 }
1276
1277 #[test]
1278 fn test_compile_numeric_comparison() {
1279 let item = make_item("EventID", &[Modifier::Gte], vec![SigmaValue::Integer(4688)]);
1280 let compiled = compile_detection_item(&item).unwrap();
1281
1282 let ev = json!({"EventID": 4688});
1283 let event = Event::from_value(&ev);
1284 assert!(eval_detection_item(&compiled, &event));
1285
1286 let ev2 = json!({"EventID": 1000});
1287 let event2 = Event::from_value(&ev2);
1288 assert!(!eval_detection_item(&compiled, &event2));
1289 }
1290
1291 #[test]
1292 fn test_windash_expansion() {
1293 let variants = expand_windash("-param -value").unwrap();
1295 assert_eq!(variants.len(), 25);
1296 assert!(variants.contains(&"-param -value".to_string()));
1298 assert!(variants.contains(&"/param -value".to_string()));
1299 assert!(variants.contains(&"-param /value".to_string()));
1300 assert!(variants.contains(&"/param /value".to_string()));
1301 assert!(variants.contains(&"\u{2013}param \u{2013}value".to_string()));
1303 assert!(variants.contains(&"\u{2014}param \u{2014}value".to_string()));
1305 assert!(variants.contains(&"\u{2015}param \u{2015}value".to_string()));
1307 assert!(variants.contains(&"/param \u{2013}value".to_string()));
1309 }
1310
1311 #[test]
1312 fn test_windash_no_dash() {
1313 let variants = expand_windash("nodash").unwrap();
1314 assert_eq!(variants.len(), 1);
1315 assert_eq!(variants[0], "nodash");
1316 }
1317
1318 #[test]
1319 fn test_windash_single_dash() {
1320 let variants = expand_windash("-v").unwrap();
1322 assert_eq!(variants.len(), 5);
1323 assert!(variants.contains(&"-v".to_string()));
1324 assert!(variants.contains(&"/v".to_string()));
1325 assert!(variants.contains(&"\u{2013}v".to_string()));
1326 assert!(variants.contains(&"\u{2014}v".to_string()));
1327 assert!(variants.contains(&"\u{2015}v".to_string()));
1328 }
1329
1330 #[test]
1331 fn test_base64_offset_patterns() {
1332 let patterns = base64_offset_patterns(b"Test");
1333 assert!(!patterns.is_empty());
1334 assert!(
1336 patterns
1337 .iter()
1338 .any(|p| p.contains("VGVzdA") || p.contains("Rlc3"))
1339 );
1340 }
1341
1342 #[test]
1343 fn test_pattern_matches() {
1344 assert!(pattern_matches("selection_*", "selection_main"));
1345 assert!(pattern_matches("selection_*", "selection_"));
1346 assert!(!pattern_matches("selection_*", "filter_main"));
1347 assert!(pattern_matches("*", "anything"));
1348 assert!(pattern_matches("*_filter", "my_filter"));
1349 assert!(pattern_matches("exact", "exact"));
1350 assert!(!pattern_matches("exact", "other"));
1351 }
1352
1353 #[test]
1354 fn test_eval_condition_and() {
1355 let items_sel = vec![make_item(
1356 "CommandLine",
1357 &[Modifier::Contains],
1358 vec![SigmaValue::String(SigmaString::new("whoami"))],
1359 )];
1360 let items_filter = vec![make_item(
1361 "User",
1362 &[],
1363 vec![SigmaValue::String(SigmaString::new("SYSTEM"))],
1364 )];
1365
1366 let mut detections = HashMap::new();
1367 detections.insert(
1368 "selection".into(),
1369 compile_detection(&Detection::AllOf(items_sel)).unwrap(),
1370 );
1371 detections.insert(
1372 "filter".into(),
1373 compile_detection(&Detection::AllOf(items_filter)).unwrap(),
1374 );
1375
1376 let cond = ConditionExpr::And(vec![
1377 ConditionExpr::Identifier("selection".into()),
1378 ConditionExpr::Not(Box::new(ConditionExpr::Identifier("filter".into()))),
1379 ]);
1380
1381 let ev = json!({"CommandLine": "whoami", "User": "admin"});
1382 let event = Event::from_value(&ev);
1383 let mut matched = Vec::new();
1384 assert!(eval_condition(&cond, &detections, &event, &mut matched));
1385
1386 let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
1387 let event2 = Event::from_value(&ev2);
1388 let mut matched2 = Vec::new();
1389 assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1390 }
1391
1392 #[test]
1393 fn test_compile_expand_modifier() {
1394 let items = vec![make_item(
1395 "path",
1396 &[Modifier::Expand],
1397 vec![SigmaValue::String(SigmaString::new(
1398 "C:\\Users\\%username%\\Downloads",
1399 ))],
1400 )];
1401 let detection = compile_detection(&Detection::AllOf(items)).unwrap();
1402
1403 let mut detections = HashMap::new();
1404 detections.insert("selection".into(), detection);
1405
1406 let cond = ConditionExpr::Identifier("selection".into());
1407
1408 let ev = json!({
1410 "path": "C:\\Users\\admin\\Downloads",
1411 "username": "admin"
1412 });
1413 let event = Event::from_value(&ev);
1414 let mut matched = Vec::new();
1415 assert!(eval_condition(&cond, &detections, &event, &mut matched));
1416
1417 let ev2 = json!({
1419 "path": "C:\\Users\\admin\\Downloads",
1420 "username": "guest"
1421 });
1422 let event2 = Event::from_value(&ev2);
1423 let mut matched2 = Vec::new();
1424 assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1425 }
1426
1427 #[test]
1428 fn test_compile_timestamp_hour_modifier() {
1429 let items = vec![make_item(
1430 "timestamp",
1431 &[Modifier::Hour],
1432 vec![SigmaValue::Integer(3)],
1433 )];
1434 let detection = compile_detection(&Detection::AllOf(items)).unwrap();
1435
1436 let mut detections = HashMap::new();
1437 detections.insert("selection".into(), detection);
1438
1439 let cond = ConditionExpr::Identifier("selection".into());
1440
1441 let ev = json!({"timestamp": "2024-07-10T03:30:00Z"});
1443 let event = Event::from_value(&ev);
1444 let mut matched = Vec::new();
1445 assert!(eval_condition(&cond, &detections, &event, &mut matched));
1446
1447 let ev2 = json!({"timestamp": "2024-07-10T12:30:00Z"});
1449 let event2 = Event::from_value(&ev2);
1450 let mut matched2 = Vec::new();
1451 assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1452 }
1453
1454 #[test]
1455 fn test_compile_timestamp_month_modifier() {
1456 let items = vec![make_item(
1457 "created",
1458 &[Modifier::Month],
1459 vec![SigmaValue::Integer(12)],
1460 )];
1461 let detection = compile_detection(&Detection::AllOf(items)).unwrap();
1462
1463 let mut detections = HashMap::new();
1464 detections.insert("selection".into(), detection);
1465
1466 let cond = ConditionExpr::Identifier("selection".into());
1467
1468 let ev = json!({"created": "2024-12-25T10:00:00Z"});
1470 let event = Event::from_value(&ev);
1471 let mut matched = Vec::new();
1472 assert!(eval_condition(&cond, &detections, &event, &mut matched));
1473
1474 let ev2 = json!({"created": "2024-07-10T10:00:00Z"});
1476 let event2 = Event::from_value(&ev2);
1477 let mut matched2 = Vec::new();
1478 assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1479 }
1480
1481 fn make_test_sigma_rule(title: &str, custom_attributes: HashMap<String, String>) -> SigmaRule {
1482 use rsigma_parser::{Detections, LogSource};
1483 SigmaRule {
1484 title: title.to_string(),
1485 id: Some("test-id".to_string()),
1486 name: None,
1487 related: vec![],
1488 taxonomy: None,
1489 status: None,
1490 level: Some(Level::Medium),
1491 description: None,
1492 license: None,
1493 author: None,
1494 references: vec![],
1495 date: None,
1496 modified: None,
1497 tags: vec![],
1498 scope: vec![],
1499 logsource: LogSource {
1500 category: Some("test".to_string()),
1501 product: None,
1502 service: None,
1503 definition: None,
1504 custom: HashMap::new(),
1505 },
1506 detection: Detections {
1507 named: {
1508 let mut m = HashMap::new();
1509 m.insert(
1510 "selection".to_string(),
1511 Detection::AllOf(vec![make_item(
1512 "action",
1513 &[],
1514 vec![SigmaValue::String(SigmaString::new("login"))],
1515 )]),
1516 );
1517 m
1518 },
1519 conditions: vec![ConditionExpr::Identifier("selection".to_string())],
1520 condition_strings: vec!["selection".to_string()],
1521 timeframe: None,
1522 },
1523 fields: vec![],
1524 falsepositives: vec![],
1525 custom_attributes,
1526 }
1527 }
1528
1529 #[test]
1530 fn test_include_event_custom_attribute() {
1531 let mut attrs = HashMap::new();
1532 attrs.insert("rsigma.include_event".to_string(), "true".to_string());
1533 let rule = make_test_sigma_rule("Include Event Test", attrs);
1534
1535 let compiled = compile_rule(&rule).unwrap();
1536 assert!(compiled.include_event);
1537
1538 let ev = json!({"action": "login", "user": "alice"});
1539 let event = Event::from_value(&ev);
1540 let result = evaluate_rule(&compiled, &event).unwrap();
1541 assert!(result.event.is_some());
1542 assert_eq!(result.event.unwrap(), ev);
1543 }
1544
1545 #[test]
1546 fn test_no_include_event_by_default() {
1547 let rule = make_test_sigma_rule("No Include Event Test", HashMap::new());
1548
1549 let compiled = compile_rule(&rule).unwrap();
1550 assert!(!compiled.include_event);
1551
1552 let ev = json!({"action": "login", "user": "alice"});
1553 let event = Event::from_value(&ev);
1554 let result = evaluate_rule(&compiled, &event).unwrap();
1555 assert!(result.event.is_none());
1556 }
1557}
1558
1559#[cfg(test)]
1564mod proptests {
1565 use super::*;
1566 use proptest::prelude::*;
1567
1568 proptest! {
1572 #[test]
1573 fn windash_count_is_5_pow_n(
1574 prefix in "[a-z]{0,5}",
1576 dashes in prop::collection::vec(Just('-'), 0..=3),
1577 suffix in "[a-z]{0,5}",
1578 ) {
1579 let mut input = prefix;
1580 for d in &dashes {
1581 input.push(*d);
1582 }
1583 input.push_str(&suffix);
1584
1585 let n = input.chars().filter(|c| *c == '-').count();
1586 let variants = expand_windash(&input).unwrap();
1587 let expected = 5usize.pow(n as u32);
1588 prop_assert_eq!(variants.len(), expected,
1589 "expand_windash({:?}) should produce {} variants, got {}",
1590 input, expected, variants.len());
1591 }
1592 }
1593
1594 proptest! {
1598 #[test]
1599 fn windash_no_duplicates(
1600 prefix in "[a-z]{0,4}",
1601 dashes in prop::collection::vec(Just('-'), 0..=2),
1602 suffix in "[a-z]{0,4}",
1603 ) {
1604 let mut input = prefix;
1605 for d in &dashes {
1606 input.push(*d);
1607 }
1608 input.push_str(&suffix);
1609
1610 let variants = expand_windash(&input).unwrap();
1611 let unique: std::collections::HashSet<&String> = variants.iter().collect();
1612 prop_assert_eq!(variants.len(), unique.len(),
1613 "expand_windash({:?}) produced duplicates", input);
1614 }
1615 }
1616
1617 proptest! {
1621 #[test]
1622 fn windash_contains_original(
1623 prefix in "[a-z]{0,5}",
1624 dashes in prop::collection::vec(Just('-'), 0..=3),
1625 suffix in "[a-z]{0,5}",
1626 ) {
1627 let mut input = prefix;
1628 for d in &dashes {
1629 input.push(*d);
1630 }
1631 input.push_str(&suffix);
1632
1633 let variants = expand_windash(&input).unwrap();
1634 prop_assert!(variants.contains(&input),
1635 "expand_windash({:?}) should contain the original", input);
1636 }
1637 }
1638
1639 proptest! {
1644 #[test]
1645 fn windash_variants_preserve_non_dash_chars(
1646 prefix in "[a-z]{1,5}",
1647 suffix in "[a-z]{1,5}",
1648 ) {
1649 let input = format!("{prefix}-{suffix}");
1650 let variants = expand_windash(&input).unwrap();
1651 for variant in &variants {
1652 prop_assert!(variant.starts_with(&prefix),
1654 "variant {:?} should start with {:?}", variant, prefix);
1655 prop_assert!(variant.ends_with(&suffix),
1656 "variant {:?} should end with {:?}", variant, suffix);
1657 }
1658 }
1659 }
1660
1661 proptest! {
1665 #[test]
1666 fn windash_no_dashes_passthrough(text in "[a-zA-Z0-9]{1,20}") {
1667 prop_assume!(!text.contains('-'));
1668 let variants = expand_windash(&text).unwrap();
1669 prop_assert_eq!(variants.len(), 1);
1670 prop_assert_eq!(&variants[0], &text);
1671 }
1672 }
1673}