diff --git a/crates/parapet/src/collections.rs b/crates/parapet/src/collections.rs index 6a3d719..f5ae4de 100644 --- a/crates/parapet/src/collections.rs +++ b/crates/parapet/src/collections.rs @@ -8,7 +8,39 @@ use std::borrow::Cow; use crate::macros::MacroContext; -use crate::rule::{Collection, Selector, Target}; +use crate::rule::Collection; + +/// A target whose member-selector is compiled, ready to resolve without any +/// per-request work. +/// +/// The parsed [`crate::rule::Target`] keeps its selector as a string so the +/// rule set stays serialisable; compilation turns it into this, where a regex +/// selector is a compiled automaton rather than a pattern to rebuild on every +/// resolution. A rule set with many regex selectors resolved thousands of +/// values per request, and recompiling the selector for each one measured as +/// the dominant cost of inspection. +#[derive(Debug, Clone)] +pub struct CompiledTarget { + /// The collection to read. + pub collection: Collection, + /// Which members to keep, if narrowed. + pub selector: Option, + /// A leading `!`: remove these members from the result. + pub exclusion: bool, + /// A leading `&`: inspect the member count, not the values. + pub count: bool, +} + +/// A member-selector, compiled. +#[derive(Debug, Clone)] +pub enum CompiledSelector { + /// `ARGS:username`, an exact member name. + Name(String), + /// `REQUEST_HEADERS:/^X-/`, compiled once. + Regex(regex::Regex), + /// `XML:/*`, an XPath expression, kept as authored. + XPath(String), +} /// One resolved member of a collection. /// @@ -193,9 +225,9 @@ impl Variables { /// /// Exclusions (`!ARGS:x`) are applied after collection, so order within /// the target list does not matter, matching SecLang. - pub fn resolve(&self, targets: &[Target]) -> Vec> { + pub fn resolve(&self, targets: &[CompiledTarget]) -> Vec> { let mut out: Vec> = Vec::new(); - let mut excluded: Vec<(Collection, Option<&Selector>)> = Vec::new(); + let mut excluded: Vec<(Collection, Option<&CompiledSelector>)> = Vec::new(); for target in targets { if target.exclusion { @@ -214,23 +246,19 @@ impl Variables { } match selector { None => true, - Some(Selector::Name(n)) => value + Some(CompiledSelector::Name(n)) => value .name .strip_prefix(prefix) .and_then(|r| r.strip_prefix(':')) .is_some_and(|member| member.eq_ignore_ascii_case(n)), - Some(Selector::Regex(pattern)) => value + Some(CompiledSelector::Regex(re)) => value .name .strip_prefix(prefix) .and_then(|r| r.strip_prefix(':')) - .is_some_and(|member| { - regex::Regex::new(pattern) - .map(|re| re.is_match(member)) - .unwrap_or(false) - }), + .is_some_and(|member| re.is_match(member)), // An XPath exclusion cannot be evaluated without an // XML tree, and XML is never populated yet. - Some(Selector::XPath(_)) => false, + Some(CompiledSelector::XPath(_)) => false, } }) }); @@ -238,7 +266,7 @@ impl Variables { out } - fn resolve_one<'a>(&'a self, target: &Target, out: &mut Vec>) { + fn resolve_one<'a>(&'a self, target: &CompiledTarget, out: &mut Vec>) { use Collection::*; let prefix = collection_name(target.collection); @@ -308,10 +336,10 @@ impl Variables { // else is refused at compile time, so reaching here with another // form is impossible rather than silently empty. Xml => match target.selector.as_ref() { - Some(Selector::XPath(expr)) if expr.trim() == "/*" => { + Some(CompiledSelector::XPath(expr)) if expr.trim() == "/*" => { push_map(out, prefix, &self.xml_elements, None) } - Some(Selector::XPath(expr)) if expr.trim() == "//@*" => { + Some(CompiledSelector::XPath(expr)) if expr.trim() == "//@*" => { push_map(out, prefix, &self.xml_attributes, None) } _ => {} @@ -331,10 +359,10 @@ impl Variables { /// that one header is present, not how many headers there are. Counting /// the whole collection instead makes every presence check true, which /// silently fires the rules that test for a header being absent. - fn count_of(&self, target: &Target) -> usize { + fn count_of(&self, target: &CompiledTarget) -> usize { let mut resolved = Vec::new(); self.resolve_one( - &Target { + &CompiledTarget { collection: target.collection, selector: target.selector.clone(), exclusion: false, @@ -421,16 +449,14 @@ fn push_map<'a>( out: &mut Vec>, prefix: &'a str, map: &'a Multimap, - selector: Option<&Selector>, + selector: Option<&CompiledSelector>, ) { for (name, value) in map.iter() { let keep = match selector { None => true, - Some(Selector::Name(want)) => name.eq_ignore_ascii_case(want), - Some(Selector::Regex(pattern)) => regex::Regex::new(pattern) - .map(|re| re.is_match(name)) - .unwrap_or(false), - Some(Selector::XPath(_)) => false, + Some(CompiledSelector::Name(want)) => name.eq_ignore_ascii_case(want), + Some(CompiledSelector::Regex(re)) => re.is_match(name), + Some(CompiledSelector::XPath(_)) => false, }; if keep { out.push(Value { diff --git a/crates/parapet/src/engine.rs b/crates/parapet/src/engine.rs index 8fd8999..66c094a 100644 --- a/crates/parapet/src/engine.rs +++ b/crates/parapet/src/engine.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use crate::action::{Action, Ctl, SetVar, SetVarOp, Transformation}; +use crate::collections::{CompiledSelector, CompiledTarget}; use crate::macros::Template; use crate::matcher::{CompiledOperator, DataLoader, OperatorCompileError}; use crate::rule::{Collection, Directive, Rule, Selector, Severity, Target}; @@ -39,7 +40,7 @@ pub struct SetVarSpec { #[derive(Debug)] pub struct ChainLink { /// Variables this link inspects. - pub targets: Vec, + pub targets: Vec, /// The link's test. pub operator: Option, /// Whether the link's result is inverted. @@ -60,7 +61,7 @@ pub struct CompiledRule { /// Which phase the rule runs in. pub phase: Phase, /// Variables the rule inspects. Empty for `SecAction`. - pub targets: Vec, + pub targets: Vec, /// The rule's test. `None` means always match, as `SecAction` does. pub operator: Option, /// Whether the operator result is inverted. @@ -365,14 +366,14 @@ fn compile_rule( ) -> Result { let id = starter.id(); let line = starter.line; - check_targets(&starter.targets, id, line)?; + let targets = compile_targets(&starter.targets, id, line)?; let operator = compile_operator(starter, loader)?; let mut compiled = CompiledRule { id, // SecLang defaults a rule with no explicit phase to phase 2. phase: Phase::RequestBody, - targets: starter.targets.clone(), + targets, operator, negated: starter.negated, transformations: Vec::new(), @@ -397,13 +398,13 @@ fn compile_rule( } for link in links { - check_targets( + let link_targets = compile_targets( &link.targets, link.id().unwrap_or(id.unwrap_or(0)), link.line, )?; let mut chain_link = ChainLink { - targets: link.targets.clone(), + targets: link_targets, operator: compile_operator(link, loader)?, negated: link.negated, transformations: Vec::new(), @@ -426,44 +427,57 @@ fn compile_rule( Ok(compiled) } -/// Reject targets Parapet cannot resolve, before they become silent no-ops. +/// Compile a target list into its evaluable form. /// -/// A selector that cannot be evaluated selects nothing, and a rule that -/// inspects nothing cannot fire. Both an unsupported XPath and an uncompilable -/// regex selector are refused here so that failure surfaces at compile time -/// rather than as a silent bypass at request time. -fn check_targets( +/// A regex selector is compiled once here rather than on every resolution, and +/// a selector that cannot be evaluated is refused rather than left to select +/// nothing at request time: a rule that inspects nothing cannot fire, and it +/// would do so silently. An unsupported XPath and an uncompilable regex +/// selector both surface here, at compile time. +fn compile_targets( targets: &[Target], id: impl Into>, line: usize, -) -> Result<(), CompileError> { +) -> Result, CompileError> { let id = id.into(); - for target in targets { - match &target.selector { - Some(Selector::XPath(expression)) if target.collection == Collection::Xml => { - if !crate::xml::xpath_is_supported(expression) { - return Err(CompileError::UnsupportedXPath { - id: id.unwrap_or(0), - line, - expression: expression.clone(), - supported: crate::xml::SUPPORTED_XPATH, - }); + targets + .iter() + .map(|target| { + let selector = match &target.selector { + None => None, + Some(Selector::Name(name)) => Some(CompiledSelector::Name(name.clone())), + Some(Selector::XPath(expression)) => { + if target.collection == Collection::Xml + && !crate::xml::xpath_is_supported(expression) + { + return Err(CompileError::UnsupportedXPath { + id: id.unwrap_or(0), + line, + expression: expression.clone(), + supported: crate::xml::SUPPORTED_XPATH, + }); + } + Some(CompiledSelector::XPath(expression.clone())) } - } - // The same engine compiles this at resolve time. Validating it here - // with the same constructor guarantees that never fails silently. - Some(Selector::Regex(pattern)) => { - regex::Regex::new(pattern).map_err(|e| CompileError::InvalidSelector { - id: id.unwrap_or(0), - line, - selector: pattern.clone(), - error: e.to_string(), - })?; - } - _ => {} - } - } - Ok(()) + Some(Selector::Regex(pattern)) => { + let re = + regex::Regex::new(pattern).map_err(|e| CompileError::InvalidSelector { + id: id.unwrap_or(0), + line, + selector: pattern.clone(), + error: e.to_string(), + })?; + Some(CompiledSelector::Regex(re)) + } + }; + Ok(CompiledTarget { + collection: target.collection, + selector, + exclusion: target.exclusion, + count: target.count, + }) + }) + .collect() } fn compile_operator( diff --git a/crates/parapet/src/transaction.rs b/crates/parapet/src/transaction.rs index 096e385..3b9db73 100644 --- a/crates/parapet/src/transaction.rs +++ b/crates/parapet/src/transaction.rs @@ -5,10 +5,9 @@ //! first disruptive action, as SecLang specifies. use crate::action::{Ctl, RuleEngineMode, SetVarOp, Transformation}; -use crate::collections::{BodyError, OwnedValue, Variables}; +use crate::collections::{BodyError, CompiledTarget, OwnedValue, Variables}; use crate::engine::{ChainLink, CompiledRule, Disruptive, RuleSet, SetVarSpec}; use crate::matcher::CompiledOperator; -use crate::rule::Target; use crate::{Phase, Verdict}; /// Whether the engine blocks or only records. @@ -68,7 +67,7 @@ pub struct Transaction<'r> { /// One operator evaluation: what to inspect, how to prepare it, and what to /// test it with. struct Step<'a> { - targets: &'a [Target], + targets: &'a [CompiledTarget], operator: &'a CompiledOperator, negated: bool, transformations: &'a [Transformation],