1use crate::model::{CanonicalId, Component, NormalizedSbom};
7use rayon::prelude::*;
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11#[derive(Debug, Clone)]
13pub struct NormalizedEntry {
14 pub normalized_purl: Option<String>,
16 pub normalized_name: String,
18 pub name_length: usize,
20 pub ecosystem: Option<String>,
22 pub prefix: String,
24 pub trigrams: Vec<String>,
26}
27
28pub struct ComponentIndex {
38 by_ecosystem: HashMap<String, Vec<Arc<CanonicalId>>>,
40 by_prefix: HashMap<String, Vec<Arc<CanonicalId>>>,
42 by_trigram: HashMap<String, Vec<Arc<CanonicalId>>>,
44 entries: HashMap<Arc<CanonicalId>, NormalizedEntry>,
46 all_ids: Vec<Arc<CanonicalId>>,
48}
49
50impl ComponentIndex {
51 #[must_use]
56 pub fn build(sbom: &NormalizedSbom) -> Self {
57 let mut by_ecosystem: HashMap<String, Vec<Arc<CanonicalId>>> = HashMap::new();
58 let mut by_prefix: HashMap<String, Vec<Arc<CanonicalId>>> = HashMap::new();
59 let mut by_trigram: HashMap<String, Vec<Arc<CanonicalId>>> = HashMap::new();
60 let mut entries: HashMap<Arc<CanonicalId>, NormalizedEntry> = HashMap::new();
61 let mut all_ids: Vec<Arc<CanonicalId>> = Vec::new();
62
63 for (id, comp) in &sbom.components {
64 let entry = Self::normalize_component(comp);
65 let arc_id = Arc::new(id.clone());
67
68 if let Some(ref eco) = entry.ecosystem {
70 by_ecosystem
71 .entry(eco.clone())
72 .or_default()
73 .push(Arc::clone(&arc_id));
74 }
75
76 if !entry.prefix.is_empty() {
78 by_prefix
79 .entry(entry.prefix.clone())
80 .or_default()
81 .push(Arc::clone(&arc_id));
82 }
83
84 for trigram in &entry.trigrams {
86 by_trigram
87 .entry(trigram.clone())
88 .or_default()
89 .push(Arc::clone(&arc_id));
90 }
91
92 entries.insert(Arc::clone(&arc_id), entry);
93 all_ids.push(arc_id);
94 }
95
96 Self {
97 by_ecosystem,
98 by_prefix,
99 by_trigram,
100 entries,
101 all_ids,
102 }
103 }
104
105 #[must_use]
107 pub fn normalize_component(comp: &Component) -> NormalizedEntry {
108 let (ecosystem, normalized_purl) = comp.identifiers.purl.as_ref().map_or_else(
110 || {
111 (
114 comp.ecosystem
115 .as_ref()
116 .map(std::string::ToString::to_string),
117 None,
118 )
119 },
120 |purl| {
121 let eco = Self::extract_ecosystem(purl);
122 let normalized = Self::normalize_purl(purl);
123 (eco, Some(normalized))
124 },
125 );
126
127 let normalized_name = Self::normalize_name(&comp.name, ecosystem.as_deref());
129 let name_length = normalized_name.len();
130 let prefix = normalized_name.chars().take(3).collect::<String>();
131 let trigrams = Self::compute_trigrams(&normalized_name);
132
133 NormalizedEntry {
134 normalized_purl,
135 normalized_name,
136 name_length,
137 ecosystem,
138 prefix,
139 trigrams,
140 }
141 }
142
143 fn compute_trigrams(name: &str) -> Vec<String> {
148 if name.len() < 3 {
149 return if name.is_empty() {
151 vec![]
152 } else {
153 vec![name.to_string()]
154 };
155 }
156
157 if name.is_ascii() {
160 return name
161 .as_bytes()
162 .windows(3)
163 .map(|w| {
164 unsafe { std::str::from_utf8_unchecked(w) }.to_string()
167 })
168 .collect();
169 }
170
171 let chars: Vec<char> = name.chars().collect();
173 if chars.len() < 3 {
174 return vec![name.to_string()];
175 }
176
177 chars
178 .windows(3)
179 .map(|w| w.iter().collect::<String>())
180 .collect()
181 }
182
183 fn extract_ecosystem(purl: &str) -> Option<String> {
185 if let Some(rest) = purl.strip_prefix("pkg:")
187 && let Some(slash_pos) = rest.find('/')
188 {
189 return Some(rest[..slash_pos].to_lowercase());
190 }
191 None
192 }
193
194 fn normalize_purl(purl: &str) -> String {
196 let purl_lower = purl.to_lowercase();
198 if let Some(at_pos) = purl_lower.rfind('@') {
200 purl_lower[..at_pos].to_string()
201 } else {
202 purl_lower
203 }
204 }
205
206 #[must_use]
216 pub fn normalize_name(name: &str, ecosystem: Option<&str>) -> String {
217 let mut normalized = name.to_lowercase();
218
219 match ecosystem {
221 Some("pypi") => {
222 normalized = normalized.replace(['_', '.'], "-");
224 }
225 Some("cargo") => {
226 normalized = normalized.replace('-', "_");
228 }
229 Some("npm") => {
230 }
233 _ => {
234 normalized = normalized.replace('_', "-");
236 }
237 }
238
239 while normalized.contains("--") {
241 normalized = normalized.replace("--", "-");
242 }
243
244 normalized
245 }
246
247 #[must_use]
249 pub fn get_entry(&self, id: &CanonicalId) -> Option<&NormalizedEntry> {
250 self.entries.get(id)
252 }
253
254 #[must_use]
259 pub fn get_by_ecosystem(&self, ecosystem: &str) -> Option<Vec<CanonicalId>> {
260 self.by_ecosystem
261 .get(ecosystem)
262 .map(|v| v.iter().map(|arc| (**arc).clone()).collect())
263 }
264
265 #[must_use]
273 pub fn find_candidates(
274 &self,
275 source_id: &CanonicalId,
276 source_entry: &NormalizedEntry,
277 max_candidates: usize,
278 max_length_diff: usize,
279 ) -> Vec<CanonicalId> {
280 let mut candidates: Vec<Arc<CanonicalId>> = Vec::new();
281 let mut seen: HashSet<Arc<CanonicalId>> = HashSet::new();
282
283 let source_trigrams: HashSet<&str> =
285 source_entry.trigrams.iter().map(String::as_str).collect();
286
287 if let Some(ref eco) = source_entry.ecosystem
294 && let Some(ids) = self.by_ecosystem.get(eco)
295 {
296 let mut ranked: Vec<(usize, &Arc<CanonicalId>)> = Vec::new();
297 for id in ids {
298 if id.as_ref() != source_id
299 && !seen.contains(id)
300 && let Some(entry) = self.entries.get(id.as_ref())
301 {
302 let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
303 .unsigned_abs() as usize;
304 if len_diff <= max_length_diff {
305 let overlap = entry
306 .trigrams
307 .iter()
308 .filter(|t| source_trigrams.contains(t.as_str()))
309 .count();
310 ranked.push((overlap, id));
311 }
312 }
313 }
314 let rank_order = |a: &(usize, &Arc<CanonicalId>), b: &(usize, &Arc<CanonicalId>)| {
321 b.0.cmp(&a.0).then_with(|| a.1.value().cmp(b.1.value()))
322 };
323 let keep = max_candidates.min(ranked.len());
324 if keep > 0 {
325 if keep < ranked.len() {
326 ranked.select_nth_unstable_by(keep - 1, rank_order);
327 ranked.truncate(keep);
328 }
329 ranked.sort_by(rank_order);
330 for (_, id) in ranked {
331 candidates.push(Arc::clone(id));
332 seen.insert(Arc::clone(id));
333 }
334 }
335 }
336
337 if candidates.len() < max_candidates
339 && !source_entry.prefix.is_empty()
340 && let Some(ids) = self.by_prefix.get(&source_entry.prefix)
341 {
342 for id in ids {
343 if id.as_ref() != source_id
344 && !seen.contains(id)
345 && let Some(entry) = self.entries.get(id.as_ref())
346 {
347 let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
348 .unsigned_abs() as usize;
349 if len_diff <= max_length_diff {
350 candidates.push(Arc::clone(id));
351 seen.insert(Arc::clone(id));
352 }
353 }
354 if candidates.len() >= max_candidates {
355 break;
356 }
357 }
358 }
359
360 if candidates.len() < max_candidates && source_entry.prefix.len() >= 2 {
363 let prefix_2 = &source_entry.prefix[..2.min(source_entry.prefix.len())];
364 let mut similar_prefixes: Vec<_> = self
365 .by_prefix
366 .iter()
367 .filter(|(prefix, _)| {
368 prefix.starts_with(prefix_2) && *prefix != &source_entry.prefix
369 })
370 .collect();
371 similar_prefixes.sort_by(|a, b| a.0.cmp(b.0));
372
373 for (_prefix, ids) in similar_prefixes {
374 for id in ids {
375 if id.as_ref() != source_id
376 && !seen.contains(id)
377 && let Some(entry) = self.entries.get(id.as_ref())
378 {
379 let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
380 .unsigned_abs() as usize;
381 if len_diff <= max_length_diff {
382 candidates.push(Arc::clone(id));
383 seen.insert(Arc::clone(id));
384 }
385 }
386 if candidates.len() >= max_candidates {
387 break;
388 }
389 }
390 if candidates.len() >= max_candidates {
391 break;
392 }
393 }
394 }
395
396 if candidates.len() < max_candidates && !source_entry.trigrams.is_empty() {
399 let mut trigram_scores: HashMap<Arc<CanonicalId>, usize> = HashMap::new();
401
402 for trigram in &source_entry.trigrams {
403 if let Some(ids) = self.by_trigram.get(trigram) {
404 for id in ids {
405 if id.as_ref() != source_id && !seen.contains(id) {
406 *trigram_scores.entry(Arc::clone(id)).or_default() += 1;
407 }
408 }
409 }
410 }
411
412 let min_shared = if source_entry.trigrams.len() <= 2 {
414 1
415 } else {
416 2
417 };
418
419 let mut scored: Vec<_> = trigram_scores
422 .into_iter()
423 .filter(|(_, count)| *count >= min_shared)
424 .collect();
425 scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.value().cmp(b.0.value())));
426
427 for (id, _score) in scored {
428 if candidates.len() >= max_candidates {
429 break;
430 }
431 if let Some(entry) = self.entries.get(id.as_ref()) {
432 let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
433 .unsigned_abs() as usize;
434 if len_diff <= max_length_diff {
435 candidates.push(Arc::clone(&id));
436 seen.insert(id);
437 }
438 }
439 }
440 }
441
442 candidates.truncate(max_candidates);
444 candidates.into_iter().map(|arc| (*arc).clone()).collect()
445 }
446
447 #[must_use]
451 pub fn all_ids(&self) -> Vec<CanonicalId> {
452 self.all_ids.iter().map(|arc| (**arc).clone()).collect()
453 }
454
455 #[must_use]
457 pub fn len(&self) -> usize {
458 self.entries.len()
459 }
460
461 #[must_use]
463 pub fn is_empty(&self) -> bool {
464 self.entries.is_empty()
465 }
466
467 #[must_use]
474 pub fn find_candidates_parallel<'a>(
475 &self,
476 sources: &[(&'a CanonicalId, &NormalizedEntry)],
477 max_candidates: usize,
478 max_length_diff: usize,
479 ) -> Vec<(&'a CanonicalId, Vec<CanonicalId>)> {
480 sources
481 .par_iter()
482 .map(|(source_id, source_entry)| {
483 let candidates =
484 self.find_candidates(source_id, source_entry, max_candidates, max_length_diff);
485 (*source_id, candidates)
486 })
487 .collect()
488 }
489
490 #[must_use]
495 pub fn find_all_candidates_from(
496 &self,
497 other: &Self,
498 max_candidates: usize,
499 max_length_diff: usize,
500 ) -> Vec<(CanonicalId, Vec<CanonicalId>)> {
501 let sources: Vec<_> = other.entries.iter().collect();
502
503 sources
504 .par_iter()
505 .map(|(source_id, source_entry)| {
506 let candidates =
507 self.find_candidates(source_id, source_entry, max_candidates, max_length_diff);
508 ((*source_id).as_ref().clone(), candidates)
510 })
511 .collect::<Vec<_>>()
512 }
513
514 pub fn stats(&self) -> IndexStats {
516 let ecosystems = self.by_ecosystem.len();
517 let prefixes = self.by_prefix.len();
518 let trigrams = self.by_trigram.len();
519 let avg_per_ecosystem = if ecosystems > 0 {
520 self.by_ecosystem
521 .values()
522 .map(std::vec::Vec::len)
523 .sum::<usize>()
524 / ecosystems
525 } else {
526 0
527 };
528 let avg_per_prefix = if prefixes > 0 {
529 self.by_prefix
530 .values()
531 .map(std::vec::Vec::len)
532 .sum::<usize>()
533 / prefixes
534 } else {
535 0
536 };
537 let avg_per_trigram = if trigrams > 0 {
538 self.by_trigram
539 .values()
540 .map(std::vec::Vec::len)
541 .sum::<usize>()
542 / trigrams
543 } else {
544 0
545 };
546
547 IndexStats {
548 total_components: self.entries.len(),
549 ecosystems,
550 prefixes,
551 trigrams,
552 avg_per_ecosystem,
553 avg_per_prefix,
554 avg_per_trigram,
555 }
556 }
557
558 #[must_use]
562 pub fn trigram_similarity(entry_a: &NormalizedEntry, entry_b: &NormalizedEntry) -> f64 {
563 if entry_a.trigrams.is_empty() || entry_b.trigrams.is_empty() {
564 return 0.0;
565 }
566
567 let set_a: HashSet<_> = entry_a.trigrams.iter().collect();
568 let set_b: HashSet<_> = entry_b.trigrams.iter().collect();
569
570 let intersection = set_a.intersection(&set_b).count();
571 let union = set_a.union(&set_b).count();
572
573 if union == 0 {
574 0.0
575 } else {
576 intersection as f64 / union as f64
577 }
578 }
579}
580
581#[derive(Debug, Clone)]
583pub struct IndexStats {
584 pub total_components: usize,
586 pub ecosystems: usize,
588 pub prefixes: usize,
590 pub trigrams: usize,
592 pub avg_per_ecosystem: usize,
594 pub avg_per_prefix: usize,
596 pub avg_per_trigram: usize,
598}
599
600pub struct BatchCandidateGenerator {
615 component_index: ComponentIndex,
617 lsh_index: Option<super::lsh::LshIndex>,
619 cross_ecosystem_db: Option<super::cross_ecosystem::CrossEcosystemDb>,
621 config: BatchCandidateConfig,
623}
624
625#[derive(Debug, Clone)]
627pub struct BatchCandidateConfig {
628 pub max_candidates: usize,
631 pub max_length_diff: usize,
633 pub lsh_threshold: usize,
635 pub enable_cross_ecosystem: bool,
637}
638
639impl Default for BatchCandidateConfig {
640 fn default() -> Self {
641 Self {
642 max_candidates: 50,
645 max_length_diff: 5,
646 lsh_threshold: 500, enable_cross_ecosystem: true,
648 }
649 }
650}
651
652#[derive(Debug)]
654pub struct BatchCandidateResult {
655 pub source_id: CanonicalId,
657 pub index_candidates: Vec<CanonicalId>,
659 pub lsh_candidates: Vec<CanonicalId>,
661 pub cross_ecosystem_candidates: Vec<CanonicalId>,
663 pub total_unique: usize,
665}
666
667impl BatchCandidateGenerator {
668 #[must_use]
670 pub fn build(sbom: &NormalizedSbom, config: BatchCandidateConfig) -> Self {
671 let component_index = ComponentIndex::build(sbom);
672
673 let lsh_index = if sbom.component_count() >= config.lsh_threshold {
675 Some(super::lsh::LshIndex::build(
676 sbom,
677 super::lsh::LshConfig::default(),
678 ))
679 } else {
680 None
681 };
682
683 let cross_ecosystem_db = if config.enable_cross_ecosystem {
685 Some(super::cross_ecosystem::CrossEcosystemDb::with_builtin_mappings())
686 } else {
687 None
688 };
689
690 Self {
691 component_index,
692 lsh_index,
693 cross_ecosystem_db,
694 config,
695 }
696 }
697
698 pub fn find_candidates(
700 &self,
701 source_id: &CanonicalId,
702 source_component: &Component,
703 ) -> BatchCandidateResult {
704 let mut seen: HashSet<CanonicalId> = HashSet::new();
705
706 let source_entry = self.component_index.get_entry(source_id).map_or_else(
708 || {
709 ComponentIndex::normalize_component(source_component)
711 },
712 NormalizedEntry::clone,
713 );
714
715 let index_candidates = self.component_index.find_candidates(
717 source_id,
718 &source_entry,
719 self.config.max_candidates,
720 self.config.max_length_diff,
721 );
722 for id in &index_candidates {
723 seen.insert(id.clone());
724 }
725
726 let lsh_budget = self
730 .config
731 .max_candidates
732 .saturating_sub(seen.len())
733 .min(self.config.max_candidates / 2);
734 let lsh_candidates: Vec<CanonicalId> =
735 self.lsh_index.as_ref().map_or_else(Vec::new, |lsh| {
736 let candidates: Vec<_> = lsh
737 .find_candidates(source_component)
738 .into_iter()
739 .filter(|id| id != source_id && !seen.contains(id))
740 .take(lsh_budget)
741 .collect();
742 for id in &candidates {
743 seen.insert(id.clone());
744 }
745 candidates
746 });
747
748 let cross_eco_budget = self
751 .config
752 .max_candidates
753 .saturating_sub(seen.len())
754 .min(self.config.max_candidates / 4);
755 let cross_ecosystem_candidates: Vec<CanonicalId> = if let (Some(db), Some(eco)) =
756 (&self.cross_ecosystem_db, &source_component.ecosystem)
757 {
758 let candidates: Vec<_> = db
759 .find_equivalents(eco, &source_component.name)
760 .into_iter()
761 .flat_map(|m| {
762 let target_eco_str = m.target_ecosystem.to_string().to_lowercase();
764 self.component_index
765 .get_by_ecosystem(&target_eco_str)
766 .unwrap_or_default()
767 })
768 .filter(|id| id != source_id && !seen.contains(id))
769 .take(cross_eco_budget)
770 .collect();
771 for id in &candidates {
772 seen.insert(id.clone());
773 }
774 candidates
775 } else {
776 Vec::new()
777 };
778
779 let total_unique = seen.len();
780
781 BatchCandidateResult {
782 source_id: source_id.clone(),
783 index_candidates,
784 lsh_candidates,
785 cross_ecosystem_candidates,
786 total_unique,
787 }
788 }
789
790 #[must_use]
792 pub fn find_candidates_batch(
793 &self,
794 sources: &[(&CanonicalId, &Component)],
795 ) -> Vec<BatchCandidateResult> {
796 sources
797 .par_iter()
798 .map(|(id, comp)| self.find_candidates(id, comp))
799 .collect()
800 }
801
802 #[must_use]
804 pub fn all_candidates(
805 &self,
806 source_id: &CanonicalId,
807 source_component: &Component,
808 ) -> Vec<CanonicalId> {
809 let result = self.find_candidates(source_id, source_component);
810 let mut all: Vec<_> = result.index_candidates;
811 all.extend(result.lsh_candidates);
812 all.extend(result.cross_ecosystem_candidates);
813 all
814 }
815
816 #[must_use]
818 pub const fn component_index(&self) -> &ComponentIndex {
819 &self.component_index
820 }
821
822 #[must_use]
824 pub const fn has_lsh(&self) -> bool {
825 self.lsh_index.is_some()
826 }
827
828 #[must_use]
830 pub const fn has_cross_ecosystem(&self) -> bool {
831 self.cross_ecosystem_db.is_some()
832 }
833
834 pub fn stats(&self) -> BatchCandidateStats {
836 BatchCandidateStats {
837 index_stats: self.component_index.stats(),
838 lsh_enabled: self.lsh_index.is_some(),
839 lsh_stats: self.lsh_index.as_ref().map(super::lsh::LshIndex::stats),
840 cross_ecosystem_enabled: self.cross_ecosystem_db.is_some(),
841 }
842 }
843}
844
845#[derive(Debug)]
847pub struct BatchCandidateStats {
848 pub index_stats: IndexStats,
850 pub lsh_enabled: bool,
852 pub lsh_stats: Option<super::lsh::LshIndexStats>,
854 pub cross_ecosystem_enabled: bool,
856}
857
858pub struct LazyComponentIndex {
863 sbom: Option<std::sync::Arc<NormalizedSbom>>,
865 index: std::sync::OnceLock<ComponentIndex>,
867}
868
869impl LazyComponentIndex {
870 #[must_use]
872 pub const fn new(sbom: std::sync::Arc<NormalizedSbom>) -> Self {
873 Self {
874 sbom: Some(sbom),
875 index: std::sync::OnceLock::new(),
876 }
877 }
878
879 #[must_use]
881 pub fn from_index(index: ComponentIndex) -> Self {
882 let lazy = Self {
883 sbom: None,
884 index: std::sync::OnceLock::new(),
885 };
886 let _ = lazy.index.set(index);
887 lazy
888 }
889
890 pub fn get(&self) -> &ComponentIndex {
895 self.index.get_or_init(|| {
896 self.sbom.as_ref().map_or_else(
897 || {
898 ComponentIndex::build(&NormalizedSbom::default())
900 },
901 |sbom| ComponentIndex::build(sbom),
902 )
903 })
904 }
905
906 pub fn is_built(&self) -> bool {
908 self.index.get().is_some()
909 }
910
911 pub fn try_get(&self) -> Option<&ComponentIndex> {
913 self.index.get()
914 }
915}
916
917impl std::ops::Deref for LazyComponentIndex {
918 type Target = ComponentIndex;
919
920 fn deref(&self) -> &Self::Target {
921 self.get()
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928 use crate::model::{DocumentMetadata, Ecosystem};
929
930 fn make_component(name: &str, purl: Option<&str>) -> Component {
931 let mut comp = Component::new(name.to_string(), format!("test-{}", name));
932 comp.version = Some("1.0.0".to_string());
933 comp.identifiers.purl = purl.map(|s| s.to_string());
934 comp.ecosystem = purl
936 .and_then(ComponentIndex::extract_ecosystem)
937 .map(|eco_str| Ecosystem::from_purl_type(&eco_str));
938 comp
939 }
940
941 #[test]
942 fn test_extract_ecosystem() {
943 assert_eq!(
944 ComponentIndex::extract_ecosystem("pkg:pypi/requests@2.28.0"),
945 Some("pypi".to_string())
946 );
947 assert_eq!(
948 ComponentIndex::extract_ecosystem("pkg:npm/@angular/core@14.0.0"),
949 Some("npm".to_string())
950 );
951 assert_eq!(
952 ComponentIndex::extract_ecosystem("pkg:cargo/serde@1.0.0"),
953 Some("cargo".to_string())
954 );
955 }
956
957 #[test]
958 fn test_normalize_name_pypi() {
959 assert_eq!(
960 ComponentIndex::normalize_name("Python_Dateutil", Some("pypi")),
961 "python-dateutil"
962 );
963 assert_eq!(
964 ComponentIndex::normalize_name("Some.Package", Some("pypi")),
965 "some-package"
966 );
967 }
968
969 #[test]
970 fn test_normalize_name_cargo() {
971 assert_eq!(
972 ComponentIndex::normalize_name("serde-json", Some("cargo")),
973 "serde_json"
974 );
975 }
976
977 #[test]
978 fn test_build_index() {
979 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
980
981 let comp1 = make_component("requests", Some("pkg:pypi/requests@2.28.0"));
982 let comp2 = make_component("urllib3", Some("pkg:pypi/urllib3@1.26.0"));
983 let comp3 = make_component("serde", Some("pkg:cargo/serde@1.0.0"));
984
985 sbom.add_component(comp1);
986 sbom.add_component(comp2);
987 sbom.add_component(comp3);
988
989 let index = ComponentIndex::build(&sbom);
990
991 assert_eq!(index.len(), 3);
992 assert_eq!(index.by_ecosystem.get("pypi").map(|v| v.len()), Some(2));
993 assert_eq!(index.by_ecosystem.get("cargo").map(|v| v.len()), Some(1));
994 }
995
996 #[test]
997 fn test_find_candidates_same_ecosystem() {
998 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
999
1000 let comp1 = make_component("requests", Some("pkg:pypi/requests@2.28.0"));
1001 let comp2 = make_component("urllib3", Some("pkg:pypi/urllib3@1.26.0"));
1002 let comp3 = make_component("flask", Some("pkg:pypi/flask@2.0.0"));
1003 let comp4 = make_component("serde", Some("pkg:cargo/serde@1.0.0"));
1004
1005 sbom.add_component(comp1.clone());
1006 sbom.add_component(comp2);
1007 sbom.add_component(comp3);
1008 sbom.add_component(comp4);
1009
1010 let index = ComponentIndex::build(&sbom);
1011
1012 let requests_id = sbom
1014 .components
1015 .keys()
1016 .find(|id| {
1017 sbom.components
1018 .get(*id)
1019 .map(|c| c.name == "requests")
1020 .unwrap_or(false)
1021 })
1022 .unwrap();
1023
1024 let entry = index.get_entry(requests_id).unwrap();
1025 let candidates = index.find_candidates(requests_id, entry, 10, 5);
1026
1027 assert!(candidates.len() >= 2);
1029 for cand_id in &candidates {
1030 let cand_entry = index.get_entry(cand_id).unwrap();
1031 assert_eq!(cand_entry.ecosystem, Some("pypi".to_string()));
1032 }
1033 }
1034
1035 #[test]
1036 fn test_compute_trigrams() {
1037 let trigrams = ComponentIndex::compute_trigrams("lodash");
1039 assert_eq!(trigrams, vec!["lod", "oda", "das", "ash"]);
1040
1041 let trigrams = ComponentIndex::compute_trigrams("ab");
1043 assert_eq!(trigrams, vec!["ab"]);
1044
1045 let trigrams = ComponentIndex::compute_trigrams("");
1047 assert!(trigrams.is_empty());
1048
1049 let trigrams = ComponentIndex::compute_trigrams("abc");
1051 assert_eq!(trigrams, vec!["abc"]);
1052 }
1053
1054 #[test]
1055 fn test_trigram_similarity() {
1056 let entry_a = NormalizedEntry {
1057 normalized_purl: None,
1058 normalized_name: "lodash".to_string(),
1059 name_length: 6,
1060 ecosystem: None,
1061 prefix: "lod".to_string(),
1062 trigrams: vec![
1063 "lod".to_string(),
1064 "oda".to_string(),
1065 "das".to_string(),
1066 "ash".to_string(),
1067 ],
1068 };
1069
1070 let entry_b = NormalizedEntry {
1071 normalized_purl: None,
1072 normalized_name: "lodash-es".to_string(),
1073 name_length: 9,
1074 ecosystem: None,
1075 prefix: "lod".to_string(),
1076 trigrams: vec![
1077 "lod".to_string(),
1078 "oda".to_string(),
1079 "das".to_string(),
1080 "ash".to_string(),
1081 "sh-".to_string(),
1082 "h-e".to_string(),
1083 "-es".to_string(),
1084 ],
1085 };
1086
1087 let similarity = ComponentIndex::trigram_similarity(&entry_a, &entry_b);
1088 assert!(
1091 similarity > 0.5 && similarity < 0.6,
1092 "Expected ~0.57, got {}",
1093 similarity
1094 );
1095
1096 let same_similarity = ComponentIndex::trigram_similarity(&entry_a, &entry_a);
1098 assert!((same_similarity - 1.0).abs() < f64::EPSILON);
1099
1100 let entry_c = NormalizedEntry {
1102 normalized_purl: None,
1103 normalized_name: "react".to_string(),
1104 name_length: 5,
1105 ecosystem: None,
1106 prefix: "rea".to_string(),
1107 trigrams: vec!["rea".to_string(), "eac".to_string(), "act".to_string()],
1108 };
1109
1110 let diff_similarity = ComponentIndex::trigram_similarity(&entry_a, &entry_c);
1111 assert!(
1112 diff_similarity < 0.1,
1113 "Expected low similarity, got {}",
1114 diff_similarity
1115 );
1116 }
1117
1118 #[test]
1119 fn test_trigram_index_find_similar_suffix() {
1120 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1122
1123 let comp1 = make_component("react-dom", Some("pkg:npm/react-dom@18.0.0"));
1125 let comp2 = make_component("preact-dom", Some("pkg:npm/preact-dom@10.0.0")); let comp3 = make_component("angular", Some("pkg:npm/angular@15.0.0")); sbom.add_component(comp1.clone());
1129 sbom.add_component(comp2);
1130 sbom.add_component(comp3);
1131
1132 let index = ComponentIndex::build(&sbom);
1133
1134 let react_id = sbom
1136 .components
1137 .keys()
1138 .find(|id| {
1139 sbom.components
1140 .get(*id)
1141 .map(|c| c.name == "react-dom")
1142 .unwrap_or(false)
1143 })
1144 .unwrap();
1145
1146 let entry = index.get_entry(react_id).unwrap();
1147
1148 let candidates = index.find_candidates(react_id, entry, 10, 5);
1150
1151 let preact_found = candidates.iter().any(|id| {
1152 index
1153 .get_entry(id)
1154 .map(|e| e.normalized_name.contains("preact"))
1155 .unwrap_or(false)
1156 });
1157
1158 assert!(preact_found, "Should find preact-dom via trigram matching");
1159 }
1160
1161 #[test]
1166 fn test_batch_generator_enforces_total_candidate_budget() {
1167 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1168 for i in 0..80 {
1171 let name = format!("libfoo-{i:03}");
1172 let purl = format!("pkg:npm/{name}@1.0.0");
1173 sbom.add_component(make_component(&name, Some(&purl)));
1174 }
1175
1176 let max_candidates = 20;
1177 let generator = BatchCandidateGenerator::build(
1178 &sbom,
1179 BatchCandidateConfig {
1180 max_candidates,
1181 max_length_diff: 10,
1182 lsh_threshold: 1, enable_cross_ecosystem: true,
1184 },
1185 );
1186
1187 let source = make_component("libfoo-100", Some("pkg:npm/libfoo-100@1.0.0"));
1189 let result = generator.find_candidates(&source.canonical_id, &source);
1190
1191 let total = result.index_candidates.len()
1192 + result.lsh_candidates.len()
1193 + result.cross_ecosystem_candidates.len();
1194 assert!(
1195 total <= max_candidates,
1196 "candidate strategies must share one budget: got {} (index {} + lsh {} + cross-eco {}) > {}",
1197 total,
1198 result.index_candidates.len(),
1199 result.lsh_candidates.len(),
1200 result.cross_ecosystem_candidates.len(),
1201 max_candidates
1202 );
1203 assert!(result.total_unique <= max_candidates);
1204 assert_eq!(result.index_candidates.len(), max_candidates);
1206 }
1207
1208 #[test]
1214 fn test_default_candidate_budgets_agree() {
1215 let generator_default = BatchCandidateConfig::default().max_candidates;
1216 let engine_default = crate::diff::LargeSbomConfig::default().max_candidates;
1217 assert_eq!(generator_default, engine_default);
1218 assert_eq!(engine_default, 50);
1219 }
1220}