Skip to main content

sbom_tools/matching/
rule_engine.rs

1//! Rule engine for applying custom matching rules.
2//!
3//! This module provides the engine that applies custom matching rules
4//! to components during the diff process.
5
6use indexmap::IndexMap;
7use regex::Regex;
8use std::collections::{HashMap, HashSet};
9
10use crate::model::{CanonicalId, Component};
11
12use super::custom_rules::{AliasPattern, EquivalenceGroup, ExclusionRule, MatchingRulesConfig};
13
14/// Result of applying matching rules to components
15#[derive(Debug, Clone, Default)]
16pub struct RuleApplicationResult {
17    /// Original ID -> Canonical ID mapping (for equivalences)
18    pub canonical_map: HashMap<CanonicalId, CanonicalId>,
19    /// IDs that should be excluded from diff
20    pub excluded: HashSet<CanonicalId>,
21    /// Log of which rules were applied
22    pub applied_rules: Vec<AppliedRule>,
23}
24
25/// Record of a rule being applied to a component
26#[derive(Debug, Clone)]
27pub struct AppliedRule {
28    /// The component that was affected
29    pub component_id: CanonicalId,
30    /// The component name
31    pub component_name: String,
32    /// The type of rule applied
33    pub rule_type: AppliedRuleType,
34    /// Index of the rule in the config
35    pub rule_index: usize,
36    /// Name of the rule (if any)
37    pub rule_name: Option<String>,
38}
39
40/// Type of rule that was applied
41#[derive(Debug, Clone)]
42pub enum AppliedRuleType {
43    /// Component was mapped to a canonical ID
44    Equivalence { canonical: String },
45    /// Component was excluded
46    Exclusion { reason: Option<String> },
47}
48
49/// Engine for applying custom matching rules
50pub struct RuleEngine {
51    config: MatchingRulesConfig,
52    /// Compiled regex patterns for exclusions
53    compiled_exclusion_regexes: Vec<Option<Regex>>,
54    /// Compiled glob patterns for exclusions (converted to regex)
55    compiled_exclusion_globs: Vec<Option<Regex>>,
56    /// Compiled regex patterns for equivalence aliases
57    compiled_alias_regexes: Vec<Vec<Option<Regex>>>,
58    /// Compiled glob patterns for equivalence aliases (converted to regex)
59    compiled_alias_globs: Vec<Vec<Option<Regex>>>,
60}
61
62impl RuleEngine {
63    /// Create a new rule engine from configuration.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`crate::error::SbomDiffError::Matching`] when an exclusion or alias
68    /// pattern fails to compile.
69    pub fn new(config: MatchingRulesConfig) -> Result<Self, crate::error::SbomDiffError> {
70        Self::build(config).map_err(|message| crate::error::SbomDiffError::Matching {
71            context: "invalid matching rules configuration".to_string(),
72            source: crate::error::MatchingErrorKind::InvalidRule(message),
73        })
74    }
75
76    fn build(config: MatchingRulesConfig) -> Result<Self, String> {
77        // Pre-compile regex patterns for exclusions
78        let compiled_exclusion_regexes = config
79            .exclusions
80            .iter()
81            .map(|rule| match rule {
82                ExclusionRule::Exact(_) => Ok(None),
83                ExclusionRule::Conditional { regex, .. } => regex.as_ref().map_or_else(
84                    || Ok(None),
85                    |re| {
86                        Regex::new(re)
87                            .map(Some)
88                            .map_err(|e| format!("Invalid exclusion regex '{re}': {e}"))
89                    },
90                ),
91            })
92            .collect::<Result<Vec<_>, _>>()?;
93
94        // Pre-compile glob patterns for exclusions
95        let compiled_exclusion_globs = config
96            .exclusions
97            .iter()
98            .map(|rule| match rule {
99                ExclusionRule::Exact(_) => Ok(None),
100                ExclusionRule::Conditional { pattern, .. } => pattern
101                    .as_ref()
102                    .map_or_else(|| Ok(None), |pat| compile_glob(pat).map(Some)),
103            })
104            .collect::<Result<Vec<_>, _>>()?;
105
106        // Pre-compile regex patterns for equivalence aliases
107        let compiled_alias_regexes = config
108            .equivalences
109            .iter()
110            .map(|eq| {
111                eq.aliases
112                    .iter()
113                    .map(|alias| match alias {
114                        AliasPattern::Exact(_) => Ok(None),
115                        AliasPattern::Pattern { regex, .. } => regex.as_ref().map_or_else(
116                            || Ok(None),
117                            |re| {
118                                Regex::new(re)
119                                    .map(Some)
120                                    .map_err(|e| format!("Invalid alias regex '{re}': {e}"))
121                            },
122                        ),
123                    })
124                    .collect::<Result<Vec<_>, _>>()
125            })
126            .collect::<Result<Vec<_>, _>>()?;
127
128        // Pre-compile glob patterns for equivalence aliases
129        let compiled_alias_globs = config
130            .equivalences
131            .iter()
132            .map(|eq| {
133                eq.aliases
134                    .iter()
135                    .map(|alias| match alias {
136                        AliasPattern::Exact(_) => Ok(None),
137                        AliasPattern::Pattern { pattern, .. } => pattern
138                            .as_ref()
139                            .map_or_else(|| Ok(None), |pat| compile_glob(pat).map(Some)),
140                    })
141                    .collect::<Result<Vec<_>, _>>()
142            })
143            .collect::<Result<Vec<_>, _>>()?;
144
145        Ok(Self {
146            config,
147            compiled_exclusion_regexes,
148            compiled_exclusion_globs,
149            compiled_alias_regexes,
150            compiled_alias_globs,
151        })
152    }
153
154    /// Apply rules to a set of components
155    #[must_use]
156    pub fn apply(&self, components: &IndexMap<CanonicalId, Component>) -> RuleApplicationResult {
157        let mut result = RuleApplicationResult::default();
158
159        for (id, component) in components {
160            // Check exclusions first
161            if let Some(applied) = self.check_exclusions(id, component) {
162                result.excluded.insert(id.clone());
163                result.applied_rules.push(applied);
164                continue;
165            }
166
167            // Check equivalences
168            if let Some((canonical_id, applied)) = self.check_equivalences(id, component) {
169                result.canonical_map.insert(id.clone(), canonical_id);
170                result.applied_rules.push(applied);
171            }
172        }
173
174        result
175    }
176
177    /// Check if a component should be excluded
178    fn check_exclusions(&self, id: &CanonicalId, component: &Component) -> Option<AppliedRule> {
179        for (idx, rule) in self.config.exclusions.iter().enumerate() {
180            if self.exclusion_matches(rule, idx, component) {
181                return Some(AppliedRule {
182                    component_id: id.clone(),
183                    component_name: component.name.clone(),
184                    rule_type: AppliedRuleType::Exclusion {
185                        reason: rule.get_reason().map(std::string::ToString::to_string),
186                    },
187                    rule_index: idx,
188                    rule_name: None,
189                });
190            }
191        }
192        None
193    }
194
195    /// Check if an exclusion rule matches a component
196    fn exclusion_matches(
197        &self,
198        rule: &ExclusionRule,
199        rule_idx: usize,
200        component: &Component,
201    ) -> bool {
202        match rule {
203            ExclusionRule::Exact(purl) => component
204                .identifiers
205                .purl
206                .as_ref()
207                .is_some_and(|p| p == purl),
208            ExclusionRule::Conditional {
209                pattern,
210                regex: _,
211                ecosystem,
212                name,
213                scope: _,
214                reason: _,
215            } => {
216                // Check ecosystem
217                if let Some(eco) = ecosystem {
218                    let comp_eco = component
219                        .ecosystem
220                        .as_ref()
221                        .map(|e| e.to_string().to_lowercase());
222                    if comp_eco.as_deref() != Some(&eco.to_lowercase()) {
223                        return false;
224                    }
225                }
226
227                // Check name
228                if let Some(n) = name
229                    && !component.name.to_lowercase().contains(&n.to_lowercase())
230                {
231                    return false;
232                }
233
234                // Check pre-compiled glob pattern
235                if pattern.is_some() {
236                    if let Some(purl) = &component.identifiers.purl {
237                        if let Some(Some(re)) = self.compiled_exclusion_globs.get(rule_idx)
238                            && !re.is_match(purl)
239                        {
240                            return false;
241                        }
242                    } else {
243                        return false;
244                    }
245                }
246
247                // Check compiled regex
248                if let Some(Some(re)) = self.compiled_exclusion_regexes.get(rule_idx) {
249                    if let Some(purl) = &component.identifiers.purl {
250                        if !re.is_match(purl) {
251                            return false;
252                        }
253                    } else {
254                        return false;
255                    }
256                }
257
258                // If we get here and at least one condition was specified, it matched
259                ecosystem.is_some()
260                    || name.is_some()
261                    || pattern.is_some()
262                    || self
263                        .compiled_exclusion_regexes
264                        .get(rule_idx)
265                        .is_some_and(std::option::Option::is_some)
266            }
267        }
268    }
269
270    /// Check if a component matches any equivalence group
271    fn check_equivalences(
272        &self,
273        id: &CanonicalId,
274        component: &Component,
275    ) -> Option<(CanonicalId, AppliedRule)> {
276        let purl = component.identifiers.purl.as_ref()?;
277
278        for (eq_idx, eq) in self.config.equivalences.iter().enumerate() {
279            // Check if the PURL matches the canonical or any alias
280            let matches_canonical = purl == &eq.canonical;
281            let matches_alias = self.alias_matches(eq_idx, eq, purl);
282
283            if matches_canonical || matches_alias {
284                // version_sensitive scopes the equivalence to matching
285                // versions: the canonical identity is qualified with the
286                // component's version, so foo-fork@1.0.0 bridges to
287                // foo@1.0.0 but not to foo@2.0.0. (The flag was previously
288                // accepted from config and silently ignored.)
289                let canonical_id = if eq.version_sensitive {
290                    let version = component.version.as_deref().unwrap_or("");
291                    CanonicalId::from_purl(&format!("{}@{version}", eq.canonical))
292                } else {
293                    CanonicalId::from_purl(&eq.canonical)
294                };
295                let applied = AppliedRule {
296                    component_id: id.clone(),
297                    component_name: component.name.clone(),
298                    rule_type: AppliedRuleType::Equivalence {
299                        canonical: eq.canonical.clone(),
300                    },
301                    rule_index: eq_idx,
302                    rule_name: eq.name.clone(),
303                };
304                return Some((canonical_id, applied));
305            }
306        }
307
308        None
309    }
310
311    /// Check if a PURL matches any alias in an equivalence group
312    fn alias_matches(&self, eq_idx: usize, eq: &EquivalenceGroup, purl: &str) -> bool {
313        let alias_regexes = self.compiled_alias_regexes.get(eq_idx);
314        let alias_globs = self.compiled_alias_globs.get(eq_idx);
315
316        for (alias_idx, alias) in eq.aliases.iter().enumerate() {
317            let matches = match alias {
318                AliasPattern::Exact(exact_purl) => purl == exact_purl,
319                AliasPattern::Pattern {
320                    pattern: _,
321                    regex: _,
322                    ecosystem,
323                    name,
324                } => {
325                    let mut matched = false;
326
327                    // Check pre-compiled glob pattern
328                    if let Some(Some(re)) = alias_globs.and_then(|v| v.get(alias_idx))
329                        && re.is_match(purl)
330                    {
331                        matched = true;
332                    }
333
334                    // Check regex
335                    if let Some(Some(re)) = alias_regexes.and_then(|v| v.get(alias_idx))
336                        && re.is_match(purl)
337                    {
338                        matched = true;
339                    }
340
341                    // Check ecosystem match in PURL
342                    if let Some(eco) = ecosystem {
343                        let purl_lower = purl.to_lowercase();
344                        let eco_lower = eco.to_lowercase();
345                        // Check if PURL starts with pkg:<ecosystem>/
346                        if purl_lower.starts_with("pkg:")
347                            && let Some(rest) = purl_lower.strip_prefix("pkg:")
348                            && rest.starts_with(&eco_lower)
349                            && rest[eco_lower.len()..].starts_with('/')
350                        {
351                            matched = true;
352                        }
353                    }
354
355                    // Check name match in PURL
356                    if let Some(n) = name
357                        && purl.to_lowercase().contains(&n.to_lowercase())
358                    {
359                        matched = true;
360                    }
361
362                    matched
363                }
364            };
365
366            if matches {
367                return true;
368            }
369        }
370
371        false
372    }
373
374    /// Get the configuration
375    #[must_use]
376    pub const fn config(&self) -> &MatchingRulesConfig {
377        &self.config
378    }
379
380    /// Check if a PURL is excluded by any rule
381    #[must_use]
382    pub fn is_excluded(&self, purl: &str) -> bool {
383        for (idx, rule) in self.config.exclusions.iter().enumerate() {
384            match rule {
385                ExclusionRule::Exact(exact) => {
386                    if purl == exact {
387                        return true;
388                    }
389                }
390                ExclusionRule::Conditional { pattern, .. } => {
391                    // Check pre-compiled glob pattern
392                    if pattern.is_some()
393                        && let Some(Some(re)) = self.compiled_exclusion_globs.get(idx)
394                        && re.is_match(purl)
395                    {
396                        return true;
397                    }
398                    // Check pre-compiled regex
399                    if let Some(Some(re)) = self.compiled_exclusion_regexes.get(idx)
400                        && re.is_match(purl)
401                    {
402                        return true;
403                    }
404                }
405            }
406        }
407        false
408    }
409
410    /// Get the canonical PURL for a given PURL, if any equivalence applies
411    #[must_use]
412    pub fn get_canonical(&self, purl: &str) -> Option<String> {
413        for (eq_idx, eq) in self.config.equivalences.iter().enumerate() {
414            if purl == eq.canonical {
415                return Some(eq.canonical.clone());
416            }
417            if self.alias_matches(eq_idx, eq, purl) {
418                return Some(eq.canonical.clone());
419            }
420        }
421        None
422    }
423}
424
425/// Compile a glob pattern to a regex at construction time.
426fn compile_glob(pattern: &str) -> Result<Regex, String> {
427    let regex_pattern = pattern
428        .replace('.', "\\.")
429        .replace('*', ".*")
430        .replace('?', ".");
431
432    Regex::new(&format!("^{regex_pattern}$"))
433        .map_err(|e| format!("Invalid glob pattern '{pattern}': {e}"))
434}
435
436/// Simple glob pattern matching (supports * and ?) - used only in tests
437#[cfg(test)]
438fn glob_matches(pattern: &str, text: &str) -> bool {
439    compile_glob(pattern)
440        .map(|re| re.is_match(text))
441        .unwrap_or(false)
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn create_test_component(name: &str, purl: Option<&str>) -> Component {
449        use crate::model::*;
450        let mut comp = Component::new(name.to_string(), purl.unwrap_or(name).to_string());
451        comp.version = Some("1.0.0".to_string());
452        comp.identifiers.purl = purl.map(|s| s.to_string());
453        comp.ecosystem = Some(Ecosystem::Npm);
454        comp
455    }
456
457    #[test]
458    fn test_glob_matches() {
459        assert!(glob_matches("pkg:npm/*", "pkg:npm/lodash"));
460        assert!(glob_matches("pkg:npm/lodash*", "pkg:npm/lodash-es"));
461        assert!(!glob_matches("pkg:npm/*", "pkg:maven/test"));
462        assert!(glob_matches("*.json", "test.json"));
463    }
464
465    #[test]
466    fn test_exact_exclusion() {
467        let config = MatchingRulesConfig {
468            exclusions: vec![ExclusionRule::exact("pkg:npm/jest")],
469            ..Default::default()
470        };
471        let engine = RuleEngine::new(config).unwrap();
472
473        assert!(engine.is_excluded("pkg:npm/jest"));
474        assert!(!engine.is_excluded("pkg:npm/lodash"));
475    }
476
477    #[test]
478    fn test_pattern_exclusion() {
479        let config = MatchingRulesConfig {
480            exclusions: vec![ExclusionRule::pattern("pkg:npm/test-*")],
481            ..Default::default()
482        };
483        let engine = RuleEngine::new(config).unwrap();
484
485        assert!(engine.is_excluded("pkg:npm/test-utils"));
486        assert!(engine.is_excluded("pkg:npm/test-runner"));
487        assert!(!engine.is_excluded("pkg:npm/lodash"));
488    }
489
490    #[test]
491    fn test_equivalence_matching() {
492        let config = MatchingRulesConfig {
493            equivalences: vec![EquivalenceGroup {
494                name: Some("Lodash".to_string()),
495                canonical: "pkg:npm/lodash".to_string(),
496                aliases: vec![
497                    AliasPattern::exact("pkg:npm/lodash-es"),
498                    AliasPattern::glob("pkg:npm/lodash.*"),
499                ],
500                version_sensitive: false,
501            }],
502            ..Default::default()
503        };
504        let engine = RuleEngine::new(config).unwrap();
505
506        assert_eq!(
507            engine.get_canonical("pkg:npm/lodash"),
508            Some("pkg:npm/lodash".to_string())
509        );
510        assert_eq!(
511            engine.get_canonical("pkg:npm/lodash-es"),
512            Some("pkg:npm/lodash".to_string())
513        );
514        assert_eq!(
515            engine.get_canonical("pkg:npm/lodash.min"),
516            Some("pkg:npm/lodash".to_string())
517        );
518        assert_eq!(engine.get_canonical("pkg:npm/underscore"), None);
519    }
520
521    #[test]
522    fn test_apply_rules() {
523        let config = MatchingRulesConfig {
524            equivalences: vec![EquivalenceGroup {
525                name: Some("Lodash".to_string()),
526                canonical: "pkg:npm/lodash".to_string(),
527                aliases: vec![AliasPattern::exact("pkg:npm/lodash-es")],
528                version_sensitive: false,
529            }],
530            exclusions: vec![ExclusionRule::exact("pkg:npm/jest")],
531            ..Default::default()
532        };
533        let engine = RuleEngine::new(config).unwrap();
534
535        let mut components = IndexMap::new();
536        components.insert(
537            CanonicalId::from_purl("pkg:npm/lodash-es"),
538            create_test_component("lodash-es", Some("pkg:npm/lodash-es")),
539        );
540        components.insert(
541            CanonicalId::from_purl("pkg:npm/jest"),
542            create_test_component("jest", Some("pkg:npm/jest")),
543        );
544        components.insert(
545            CanonicalId::from_purl("pkg:npm/react"),
546            create_test_component("react", Some("pkg:npm/react")),
547        );
548
549        let result = engine.apply(&components);
550
551        // lodash-es should be mapped to canonical lodash
552        assert!(
553            result
554                .canonical_map
555                .contains_key(&CanonicalId::from_purl("pkg:npm/lodash-es"))
556        );
557
558        // jest should be excluded
559        assert!(
560            result
561                .excluded
562                .contains(&CanonicalId::from_purl("pkg:npm/jest"))
563        );
564
565        // react should have no rules applied
566        assert!(
567            !result
568                .canonical_map
569                .contains_key(&CanonicalId::from_purl("pkg:npm/react"))
570        );
571        assert!(
572            !result
573                .excluded
574                .contains(&CanonicalId::from_purl("pkg:npm/react"))
575        );
576
577        // Check applied rules
578        assert_eq!(result.applied_rules.len(), 2);
579    }
580}