1use 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#[derive(Debug, Clone, Default)]
16pub struct RuleApplicationResult {
17 pub canonical_map: HashMap<CanonicalId, CanonicalId>,
19 pub excluded: HashSet<CanonicalId>,
21 pub applied_rules: Vec<AppliedRule>,
23}
24
25#[derive(Debug, Clone)]
27pub struct AppliedRule {
28 pub component_id: CanonicalId,
30 pub component_name: String,
32 pub rule_type: AppliedRuleType,
34 pub rule_index: usize,
36 pub rule_name: Option<String>,
38}
39
40#[derive(Debug, Clone)]
42pub enum AppliedRuleType {
43 Equivalence { canonical: String },
45 Exclusion { reason: Option<String> },
47}
48
49pub struct RuleEngine {
51 config: MatchingRulesConfig,
52 compiled_exclusion_regexes: Vec<Option<Regex>>,
54 compiled_exclusion_globs: Vec<Option<Regex>>,
56 compiled_alias_regexes: Vec<Vec<Option<Regex>>>,
58 compiled_alias_globs: Vec<Vec<Option<Regex>>>,
60}
61
62impl RuleEngine {
63 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 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 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 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 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 #[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 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 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 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 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 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 if let Some(n) = name
229 && !component.name.to_lowercase().contains(&n.to_lowercase())
230 {
231 return false;
232 }
233
234 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 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 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 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 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 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 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 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 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 if let Some(eco) = ecosystem {
343 let purl_lower = purl.to_lowercase();
344 let eco_lower = eco.to_lowercase();
345 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 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 #[must_use]
376 pub const fn config(&self) -> &MatchingRulesConfig {
377 &self.config
378 }
379
380 #[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 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 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 #[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
425fn 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#[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 assert!(
553 result
554 .canonical_map
555 .contains_key(&CanonicalId::from_purl("pkg:npm/lodash-es"))
556 );
557
558 assert!(
560 result
561 .excluded
562 .contains(&CanonicalId::from_purl("pkg:npm/jest"))
563 );
564
565 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 assert_eq!(result.applied_rules.len(), 2);
579 }
580}