Skip to content

Commit 756e90d

Browse files
authored
perf: group multiple matches expressions inside or conditions into a RegexSet (VirusTotal#657)
When writing complex YARA rules, it is incredibly common to evaluate a single variable or structure field access (e.g., vt.net.domain.raw) against dozens of distinct regular expressions within an or condition. Previously, YARA-X evaluated each of these match operations sequentially. This PR introduces an advanced compile-time and runtime optimization that detects identical match targets within or conditions, groups their regular expressions into a single multi-pattern RegexSet, and evaluates the entire set simultaneously in a single pass.
1 parent 03584ea commit 756e90d

51 files changed

Lines changed: 1113 additions & 756 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lib/src/compiler/context.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ use yara_x_parser::ast::{Ident, WithSpan};
1010
use crate::compiler::errors::{CompileError, UnknownPattern};
1111
use crate::compiler::ir::{IR, PatternIdx};
1212
use crate::compiler::report::ReportBuilder;
13-
use crate::compiler::{Warnings, ir};
13+
use crate::compiler::{RegexId, RegexSetId, Warnings, ir};
1414
use crate::errors::{UnknownField, UnknownIdentifier};
1515
use crate::modules::BUILTIN_MODULES;
16+
use crate::string_pool::StringPool;
1617
use crate::symbols::{StackedSymbolTable, Symbol, SymbolLookup};
1718
use crate::types::Type;
1819
use crate::wasm;
@@ -65,6 +66,12 @@ pub(crate) struct CompileContext<'a, 'src> {
6566
/// Tracks the product of iteration counts of nested loops.
6667
/// Used to detect loops that may iterate an excessive number of times.
6768
pub loop_iteration_multiplier: i64,
69+
70+
/// Grouped RegexSets constructed during IR creation for or-expressions.
71+
pub regex_sets: &'a mut rustc_hash::FxHashMap<RegexSetId, Vec<RegexId>>,
72+
73+
/// Pool for regular expressions.
74+
pub regex_pool: &'a mut StringPool<RegexId>,
6875
}
6976

7077
impl<'src> CompileContext<'_, 'src> {

lib/src/compiler/emit.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use crate::compiler::ir::{
2727
};
2828
use crate::compiler::{
2929
FieldAccess, ForVars, LiteralId, OfExprTuple, OfPatternSet, PatternId,
30-
RegexpId, RuleId, RuleInfo, Var,
30+
RegexId, RuleId, RuleInfo, Var,
3131
};
3232
use crate::scanner::RuntimeObjectHandle;
3333
use crate::string_pool::{BStringPool, StringPool};
@@ -205,7 +205,7 @@ pub(crate) struct EmitContext<'a> {
205205
pub wasm_exports: &'a FxHashMap<String, FunctionId>,
206206

207207
/// Pool with regular expressions used in rule conditions.
208-
pub regexp_pool: &'a mut StringPool<RegexpId>,
208+
pub regex_pool: &'a mut StringPool<RegexId>,
209209

210210
/// Pool with literal strings used in the rules.
211211
pub lit_pool: &'a mut BStringPool<LiteralId>,
@@ -313,7 +313,7 @@ fn emit_expr(
313313
.i64_const(RuntimeString::Literal(literal_id).into_wasm());
314314
}
315315
TypeValue::Regexp(Some(regexp)) => {
316-
let re_id = ctx.regexp_pool.get_or_intern(regexp.as_str());
316+
let re_id = ctx.regex_pool.get_or_intern(regexp.as_str());
317317

318318
instr.i32_const(re_id.into());
319319
}
@@ -640,6 +640,14 @@ fn emit_expr(
640640
.call(ctx.function_id(wasm::export__str_matches.mangled_name));
641641
}
642642

643+
Expr::MatchesMany { lhs, regex_set } => {
644+
emit_expr(ctx, ir, *lhs, instr);
645+
instr.i32_const((*regex_set).into());
646+
instr.call(ctx.function_id(
647+
wasm::export__str_matches_regex_set.mangled_name,
648+
));
649+
}
650+
643651
Expr::Lookup(lookup) => {
644652
// Emit code for the primary expression (array or map) that is
645653
// being indexed.

lib/src/compiler/ir/ast2ir.rs

Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
use std::borrow::Borrow;
44
use std::cell::RefCell;
55
use std::collections::BTreeSet;
6+
use std::hash::{Hash, Hasher};
67
use std::iter;
78
use std::ops::RangeInclusive;
89
use std::rc::Rc;
910

1011
use bstr::{BString, ByteSlice};
1112
use itertools::Itertools;
12-
13+
use rustc_hash::{FxHashMap, FxHashSet, FxHasher};
1314
use yara_x_parser::Span;
1415
use yara_x_parser::ast;
1516
use yara_x_parser::ast::WithSpan;
@@ -26,12 +27,12 @@ use crate::compiler::errors::{
2627
use crate::compiler::ir::hex2hir::hex_pattern_hir_from_ast;
2728
use crate::compiler::ir::{
2829
Error, Expr, ExprId, Iterable, LiteralPattern, MatchAnchor, Pattern,
29-
PatternFlags, PatternInRule, Quantifier, Range, RegexpPattern,
30+
PatternFlags, PatternInRule, Quantifier, Range, RegexpPattern, dfs,
3031
};
3132
use crate::compiler::report::{Level, ReportBuilder};
3233
use crate::compiler::{
3334
CompileContext, CompileError, FilesizeBounds, ForVars, PatternIdx,
34-
TextPatternAsHex, warnings,
35+
RegexId, RegexSetId, TextPatternAsHex, warnings,
3536
};
3637
use crate::errors::CustomError;
3738
use crate::errors::{MethodNotAllowedInWith, PotentiallySlowLoop};
@@ -2179,40 +2180,23 @@ macro_rules! gen_n_ary_operation {
21792180

21802181
// Make sure that all operands have one of the accepted types.
21812182
for (hir, ast) in iter::zip(operands_hir.iter(), expr.operands()) {
2182-
check_type(ctx, *hir, ast.span(), accepted_types)?;
21832183
if let Some(check_fn) = check_fn {
21842184
check_fn(ctx, *hir, ast.span())?;
21852185
}
21862186
}
21872187

2188-
// Iterate the operands in pairs (first, second), (second, third),
2189-
// (third, fourth), etc.
2190-
for ((lhs_hir, rhs_ast), (rhs_hir, lhs_ast)) in
2188+
for ((lhs_hir, lhs_ast), (rhs_hir, rhs_ast)) in
21912189
iter::zip(operands_hir.iter(), expr.operands()).tuple_windows()
21922190
{
2193-
let lhs_ty = ctx.ir.get(*lhs_hir).ty();
2194-
let rhs_ty = ctx.ir.get(*rhs_hir).ty();
2195-
2196-
let types_are_compatible = {
2197-
// If the types are the same, they are compatible.
2198-
(lhs_ty == rhs_ty) ||
2199-
// If the list of compatible types contains the pair
2200-
// (lhs_ty, rhs_ty) or (rhs_ty, lhs_ty), they are
2201-
// compatible.
2202-
compatible_types.contains(&(lhs_ty, rhs_ty))
2203-
|| compatible_types.contains(&(rhs_ty, lhs_ty))
2204-
2205-
};
2206-
2207-
if !types_are_compatible {
2208-
return Err(MismatchingTypes::build(
2209-
ctx.report_builder,
2210-
lhs_ty.to_string(),
2211-
rhs_ty.to_string(),
2212-
ctx.report_builder.span_to_code_loc(expr.first().span().combine(&lhs_ast.span())),
2213-
ctx.report_builder.span_to_code_loc(rhs_ast.span()),
2214-
));
2215-
}
2191+
check_operands(
2192+
ctx,
2193+
*lhs_hir,
2194+
*rhs_hir,
2195+
expr.first().span().combine(&lhs_ast.span()),
2196+
rhs_ast.span(),
2197+
accepted_types,
2198+
compatible_types,
2199+
)?;
22162200
}
22172201

22182202
ctx.ir.$variant(operands_hir).map_err(|err| {
@@ -2275,28 +2259,123 @@ gen_n_ary_operation!(
22752259
})
22762260
);
22772261

2278-
gen_n_ary_operation!(
2279-
or_expr_from_ast,
2280-
or,
2281-
// Boolean operations accept integer, float and string operands.
2282-
// If operands are not boolean they are casted to boolean.
2283-
Type::Bool | Type::Integer | Type::Float | Type::String,
2284-
// All operand types can be mixed in a boolean operation, as they
2285-
// are casted to boolean anyways.
2286-
&[
2262+
fn or_expr_from_ast(
2263+
ctx: &mut CompileContext,
2264+
expr: &ast::NAryExpr,
2265+
) -> Result<ExprId, CompileError> {
2266+
let span = expr.span();
2267+
let accepted_types =
2268+
&[Type::Bool, Type::Integer, Type::Float, Type::String];
2269+
let compatible_types = &[
22872270
(Type::Integer, Type::Bool),
22882271
(Type::Integer, Type::Float),
22892272
(Type::Integer, Type::String),
22902273
(Type::String, Type::Bool),
22912274
(Type::String, Type::Float),
2292-
(Type::Float, Type::Bool)
2293-
],
2294-
Some(|ctx, operand, span| {
2295-
let ty = ctx.ir.get(operand).ty();
2296-
warn_if_not_bool(ctx, ty, span);
2297-
Ok(())
2275+
(Type::Float, Type::Bool),
2276+
];
2277+
2278+
// Validate operand types and emit standard warnings. Ensure all operands
2279+
// in the `or` expression conform to boolean-compatible types, checking
2280+
// for potential mismatches across adjacent pairs.
2281+
let or_operands: Vec<ExprId> = expr
2282+
.operands()
2283+
.map(|expr| expr_from_ast(ctx, expr))
2284+
.collect::<Result<Vec<ExprId>, CompileError>>()?;
2285+
2286+
for (hir, ast) in iter::zip(or_operands.iter(), expr.operands()) {
2287+
let ty = ctx.ir.get(*hir).ty();
2288+
warn_if_not_bool(ctx, ty, ast.span());
2289+
}
2290+
2291+
for ((lhs_hir, lhs_ast), (rhs_hir, rhs_ast)) in
2292+
iter::zip(or_operands.iter(), expr.operands()).tuple_windows()
2293+
{
2294+
check_operands(
2295+
ctx,
2296+
*lhs_hir,
2297+
*rhs_hir,
2298+
expr.first().span().combine(&lhs_ast.span()),
2299+
rhs_ast.span(),
2300+
accepted_types,
2301+
compatible_types,
2302+
)?;
2303+
}
2304+
2305+
// Group `matches` expressions by their left-side operand. All the `matches`
2306+
// expressions sharing the same left-side operand will be aggregated together
2307+
// into a MatchMany operation that matches the left-side operand against
2308+
// a set of regular expressions.
2309+
let mut matches_by_lhs: FxHashMap<u64, Vec<(usize, ExprId, RegexId)>> =
2310+
FxHashMap::default();
2311+
2312+
for (i, &op) in or_operands.iter().enumerate() {
2313+
if let Expr::Matches { lhs, rhs } = ctx.ir.get(op)
2314+
&& let Expr::Const(TypeValue::Regexp(Some(re))) = ctx.ir.get(*rhs)
2315+
{
2316+
let mut hasher = FxHasher::default();
2317+
for evt in ctx.ir.dfs_iter(*lhs) {
2318+
if let dfs::Event::Enter((_, expr, _)) = evt {
2319+
Hash::hash(expr, &mut hasher);
2320+
}
2321+
}
2322+
let lhs_expr_hash = hasher.finish();
2323+
let re_id = ctx.regex_pool.get_or_intern(re.as_str());
2324+
matches_by_lhs
2325+
.entry(lhs_expr_hash)
2326+
.or_default()
2327+
.push((i, *lhs, re_id));
2328+
}
2329+
}
2330+
2331+
// Collapse grouped regular expressions into `MatchesMany`. For any target
2332+
// expression associated with two or more regular expressions, construct a
2333+
// unified `RegexSet`, replace the individual `matches` nodes with a single
2334+
// `MatchesMany` expression, and record the collapsed indices.
2335+
let mut final_operands = Vec::new();
2336+
let mut collapsed_indices = FxHashSet::default();
2337+
2338+
for (_, group) in matches_by_lhs {
2339+
if group.len() >= 2 {
2340+
let set_id = RegexSetId::from(ctx.regex_sets.len() as i32);
2341+
let re_ids: Vec<_> =
2342+
group.iter().map(|&(_, _, re_id)| re_id).collect();
2343+
ctx.regex_sets.insert(set_id, re_ids);
2344+
2345+
let first_lhs = group[0].1;
2346+
let multimatch = ctx.ir.matches_regex_set(first_lhs, set_id);
2347+
2348+
final_operands.push(multimatch);
2349+
2350+
for (i, _, _) in group {
2351+
collapsed_indices.insert(i);
2352+
}
2353+
}
2354+
}
2355+
2356+
// Assemble final operands and construct the `or` expression. Preserve all
2357+
// non-collapsed operands in their original relative order. If all operands
2358+
// collapse into a single expression, return it directly without a wrapping
2359+
// `or` node.
2360+
for (i, &op) in or_operands.iter().enumerate() {
2361+
if !collapsed_indices.contains(&i) {
2362+
final_operands.push(op);
2363+
}
2364+
}
2365+
2366+
if final_operands.len() == 1 {
2367+
return Ok(final_operands[0]);
2368+
}
2369+
2370+
ctx.ir.or(final_operands).map_err(|err| match err {
2371+
Error::NumberOutOfRange => NumberOutOfRange::build(
2372+
ctx.report_builder,
2373+
i64::MIN,
2374+
i64::MAX,
2375+
ctx.report_builder.span_to_code_loc(span),
2376+
),
22982377
})
2299-
);
2378+
}
23002379

23012380
gen_unary_op!(minus_expr_from_ast, minus, Type::Integer | Type::Float, None);
23022381

lib/src/compiler/ir/dfs.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,10 @@ pub(super) fn dfs_common(
303303
stack.push(Event::Enter((*lhs, EventContext::None)));
304304
}
305305

306+
Expr::MatchesMany { lhs, .. } => {
307+
stack.push(Event::Enter((*lhs, EventContext::None)));
308+
}
309+
306310
Expr::PatternMatch { anchor, .. }
307311
| Expr::PatternMatchVar { anchor, .. } => {
308312
push_anchor(anchor, stack);

lib/src/compiler/ir/hex2hir.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ mod tests {
227227
let mut warnings = Warnings::default();
228228
let mut rule_patterns = vec![];
229229

230+
let mut regex_sets = rustc_hash::FxHashMap::default();
231+
let mut regex_pool = crate::string_pool::StringPool::new();
232+
230233
let mut ctx = CompileContext {
231234
ir: &mut ir,
232235
relaxed_re_syntax: false,
@@ -240,6 +243,8 @@ mod tests {
240243
vars: VarStack::new(),
241244
for_of_depth: 0,
242245
loop_iteration_multiplier: 1,
246+
regex_sets: &mut regex_sets,
247+
regex_pool: &mut regex_pool,
243248
};
244249

245250
let mut pattern = HexPattern::new("test_ident");

lib/src/compiler/ir/mod.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ use crate::compiler::ir::dfs::{
5151
DFSIter, DFSWithScopeIter, Event, EventContext, dfs_common,
5252
};
5353

54-
use crate::compiler::FilesizeBounds;
54+
use crate::compiler::{FilesizeBounds, RegexSetId};
5555
use crate::re;
5656
use crate::symbols::Symbol;
5757
use crate::types::Value::Const;
@@ -1556,6 +1556,20 @@ impl IR {
15561556
expr_id
15571557
}
15581558

1559+
/// Creates a new [`Expr::MatchesMany`].
1560+
pub fn matches_regex_set(
1561+
&mut self,
1562+
lhs: ExprId,
1563+
regex_set: crate::compiler::RegexSetId,
1564+
) -> ExprId {
1565+
let expr_id = ExprId::from(self.nodes.len());
1566+
self.parents[lhs.0 as usize] = expr_id;
1567+
self.parents.push(ExprId::none());
1568+
self.nodes.push(Expr::MatchesMany { lhs, regex_set });
1569+
debug_assert_eq!(self.parents.len(), self.nodes.len());
1570+
expr_id
1571+
}
1572+
15591573
/// Creates a new [`Expr::PatternMatch`]
15601574
pub fn pattern_match(
15611575
&mut self,
@@ -2000,6 +2014,7 @@ impl Debug for IR {
20002014
Expr::IEndsWith { .. } => writeln!(f, "IENDS_WITH -- hash: {expr_hash:#08x}")?,
20012015
Expr::IEquals { .. } => writeln!(f, "IEQUALS -- hash: {expr_hash:#08x}")?,
20022016
Expr::Matches { .. } => writeln!(f, "MATCHES -- hash: {expr_hash:#08x}")?,
2017+
Expr::MatchesMany { regex_set, .. } => writeln!(f, "MATCHES_MANY RegexSetId({}) -- hash: {expr_hash:#08x}", usize::from(*regex_set))?,
20032018
Expr::Defined { .. } => writeln!(f, "DEFINED -- hash: {expr_hash:#08x}")?,
20042019
Expr::FieldAccess { .. } => writeln!(f, "FIELD_ACCESS -- hash: {expr_hash:#08x}")?,
20052020
Expr::With { .. } => writeln!(f, "WITH -- hash: {expr_hash:#08x}")?,
@@ -2235,6 +2250,10 @@ pub(crate) enum Expr {
22352250
/// `matches` expression.
22362251
Matches { rhs: ExprId, lhs: ExprId },
22372252

2253+
/// Match expression similar to `Expr::Matches` but that matches against
2254+
/// multiple regular expressions at the same time.
2255+
MatchesMany { lhs: ExprId, regex_set: RegexSetId },
2256+
22382257
/// A `defined` expression (e.g. `defined foo`)
22392258
Defined { operand: ExprId },
22402259

@@ -2682,6 +2701,11 @@ impl Expr {
26822701
*rhs = replacement;
26832702
}
26842703
}
2704+
Expr::MatchesMany { lhs, .. } => {
2705+
if *lhs == child {
2706+
*lhs = replacement;
2707+
}
2708+
}
26852709
Expr::PatternMatch { anchor, .. }
26862710
| Expr::PatternMatchVar { anchor, .. } => {
26872711
replace_in_anchor(anchor)
@@ -2786,6 +2810,7 @@ impl Expr {
27862810
| Expr::IEndsWith { .. }
27872811
| Expr::IEquals { .. }
27882812
| Expr::Matches { .. }
2813+
| Expr::MatchesMany { .. }
27892814
| Expr::PatternMatch { .. }
27902815
| Expr::PatternMatchVar { .. }
27912816
| Expr::OfExprTuple(_)
@@ -2857,6 +2882,7 @@ impl Expr {
28572882
| Expr::IEndsWith { .. }
28582883
| Expr::IEquals { .. }
28592884
| Expr::Matches { .. }
2885+
| Expr::MatchesMany { .. }
28602886
| Expr::PatternMatch { .. }
28612887
| Expr::PatternMatchVar { .. }
28622888
| Expr::OfExprTuple(_)

0 commit comments

Comments
 (0)