1#![doc = include_str!("../readme.md")]
2
3pub mod versions;
4
5use indexmap::IndexMap;
6use packageurl::PackageUrl;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11use std::str::FromStr;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Sbom {
29 pub metadata: Metadata,
31 pub components: IndexMap<ComponentId, Component>,
33 pub dependencies: BTreeMap<ComponentId, BTreeMap<ComponentId, DependencyKind>>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub warnings: Vec<String>,
38}
39
40impl Default for Sbom {
41 fn default() -> Self {
42 Self {
43 metadata: Metadata::default(),
44 components: IndexMap::new(),
45 dependencies: BTreeMap::new(),
46 warnings: Vec::new(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
57pub struct Metadata {
58 pub timestamp: Option<String>,
60 pub tools: Vec<String>,
62 pub authors: Vec<String>,
64}
65
66#[derive(
76 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
77)]
78#[serde(rename_all = "lowercase")]
79pub enum DependencyKind {
80 #[default]
82 Runtime,
83 Dev,
85 Build,
87 Test,
89 Optional,
91 Provided,
93}
94
95impl fmt::Display for DependencyKind {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 Self::Runtime => write!(f, "runtime"),
99 Self::Dev => write!(f, "dev"),
100 Self::Build => write!(f, "build"),
101 Self::Test => write!(f, "test"),
102 Self::Optional => write!(f, "optional"),
103 Self::Provided => write!(f, "provided"),
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
128pub struct ComponentId(String);
129
130impl ComponentId {
131 pub fn new(purl: Option<&str>, properties: &[(&str, &str)]) -> Self {
136 if let Some(purl) = purl {
137 if let Ok(parsed) = PackageUrl::from_str(purl) {
139 return ComponentId(parsed.to_string());
140 }
141 return ComponentId(purl.to_string());
142 }
143
144 let mut hasher = Sha256::new();
146 for (k, v) in properties {
147 hasher.update(k.as_bytes());
148 hasher.update(b":");
149 hasher.update(v.as_bytes());
150 hasher.update(b"|");
151 }
152 let hash = hex::encode(hasher.finalize());
153 ComponentId(format!("h:{}", hash))
154 }
155
156 pub fn as_str(&self) -> &str {
158 &self.0
159 }
160}
161
162impl std::fmt::Display for ComponentId {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 write!(f, "{}", self.0)
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct Component {
175 pub id: ComponentId,
177 pub name: String,
179 pub version: Option<String>,
181 pub ecosystem: Option<String>,
183 pub supplier: Option<String>,
185 pub description: Option<String>,
187 pub purl: Option<String>,
189 pub licenses: BTreeSet<String>,
191 pub hashes: BTreeMap<String, String>,
193 pub source_ids: Vec<String>,
195}
196
197impl Component {
198 pub fn new(name: String, version: Option<String>) -> Self {
203 let mut props = vec![("name", name.as_str())];
204 if let Some(v) = &version {
205 props.push(("version", v));
206 }
207 let id = ComponentId::new(None, &props);
208
209 Self {
210 id,
211 name,
212 version,
213 ecosystem: None,
214 supplier: None,
215 description: None,
216 purl: None,
217 licenses: BTreeSet::new(),
218 hashes: BTreeMap::new(),
219 source_ids: Vec::new(),
220 }
221 }
222}
223
224impl Sbom {
225 pub fn normalize(&mut self) {
235 self.components.sort_keys();
237
238 for component in self.components.values_mut() {
240 component.normalize();
241 }
242
243 self.metadata.timestamp = None;
245 self.metadata.tools.clear();
246 self.metadata.authors.clear(); }
248
249 pub fn roots(&self) -> Vec<ComponentId> {
253 let targets: BTreeSet<_> = self
254 .dependencies
255 .values()
256 .flat_map(|children| children.keys())
257 .collect();
258 self.components
259 .keys()
260 .filter(|id| !targets.contains(id))
261 .cloned()
262 .collect()
263 }
264
265 pub fn deps(&self, id: &ComponentId) -> Vec<ComponentId> {
267 self.dependencies
268 .get(id)
269 .map(|d| d.keys().cloned().collect())
270 .unwrap_or_default()
271 }
272
273 pub fn rdeps(&self, id: &ComponentId) -> Vec<ComponentId> {
275 self.dependencies
276 .iter()
277 .filter(|(_, children)| children.contains_key(id))
278 .map(|(parent, _)| parent.clone())
279 .collect()
280 }
281
282 pub fn transitive_deps(&self, id: &ComponentId) -> BTreeSet<ComponentId> {
286 let mut visited = BTreeSet::new();
287 let mut stack = vec![id.clone()];
288 while let Some(current) = stack.pop() {
289 if let Some(children) = self.dependencies.get(¤t) {
290 for child in children.keys() {
291 if visited.insert(child.clone()) {
292 stack.push(child.clone());
293 }
294 }
295 }
296 }
297 visited
298 }
299
300 pub fn ecosystems(&self) -> BTreeSet<String> {
302 self.components
303 .values()
304 .filter_map(|c| c.ecosystem.clone())
305 .collect()
306 }
307
308 pub fn licenses(&self) -> BTreeSet<String> {
310 self.components
311 .values()
312 .flat_map(|c| c.licenses.iter().cloned())
313 .collect()
314 }
315
316 pub fn missing_hashes(&self) -> Vec<ComponentId> {
320 self.components
321 .iter()
322 .filter(|(_, c)| c.hashes.is_empty())
323 .map(|(id, _)| id.clone())
324 .collect()
325 }
326
327 pub fn by_purl(&self, purl: &str) -> Option<&Component> {
329 let id = ComponentId::new(Some(purl), &[]);
330 self.components.get(&id)
331 }
332
333 pub fn detect_cycles(&self) -> Vec<Vec<ComponentId>> {
341 let mut visited = BTreeSet::new();
342 let mut on_stack = BTreeSet::new();
343 let mut path = Vec::new();
344 let mut cycles = Vec::new();
345
346 for node in self.dependencies.keys() {
347 if !visited.contains(node) {
348 self.dfs_cycles(node, &mut visited, &mut on_stack, &mut path, &mut cycles);
349 }
350 }
351
352 cycles
353 }
354
355 fn dfs_cycles(
356 &self,
357 node: &ComponentId,
358 visited: &mut BTreeSet<ComponentId>,
359 on_stack: &mut BTreeSet<ComponentId>,
360 path: &mut Vec<ComponentId>,
361 cycles: &mut Vec<Vec<ComponentId>>,
362 ) {
363 visited.insert(node.clone());
364 on_stack.insert(node.clone());
365 path.push(node.clone());
366
367 if let Some(children) = self.dependencies.get(node) {
368 for child in children.keys() {
369 if !visited.contains(child) {
370 self.dfs_cycles(child, visited, on_stack, path, cycles);
371 } else if on_stack.contains(child) {
372 if let Some(start) = path.iter().position(|n| n == child) {
374 let mut cycle: Vec<_> = path[start..].to_vec();
375 cycle.push(child.clone());
376 cycles.push(cycle);
377 }
378 }
379 }
380 }
381
382 path.pop();
383 on_stack.remove(node);
384 }
385}
386
387impl Component {
388 pub fn normalize(&mut self) {
393 let normalized_hashes: BTreeMap<String, String> = self
394 .hashes
395 .iter()
396 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
397 .collect();
398 self.hashes = normalized_hashes;
399 }
400}
401
402pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
416 PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
417}
418
419pub fn parse_license_expression(license: &str) -> BTreeSet<String> {
439 match spdx::Expression::parse(license) {
440 Ok(expr) => {
441 let ids: BTreeSet<String> = expr
442 .requirements()
443 .map(|r| match &r.req.license {
444 spdx::LicenseItem::Spdx { id, .. } => id.name.to_string(),
445 other => other.to_string(),
446 })
447 .collect();
448 if ids.is_empty() {
449 BTreeSet::from([license.to_string()])
451 } else {
452 ids
453 }
454 }
455 Err(_) => {
456 BTreeSet::from([license.to_string()])
458 }
459 }
460}
461
462pub fn canonical_algorithm_name(name: &str) -> String {
477 match name.replace('-', "").to_uppercase().as_str() {
478 "MD2" => "MD2",
479 "MD4" => "MD4",
480 "MD5" => "MD5",
481 "MD6" => "MD6",
482 "SHA1" => "SHA-1",
483 "SHA224" => "SHA-224",
484 "SHA256" => "SHA-256",
485 "SHA384" => "SHA-384",
486 "SHA512" => "SHA-512",
487 "SHA3256" => "SHA3-256",
488 "SHA3384" => "SHA3-384",
489 "SHA3512" => "SHA3-512",
490 "BLAKE2B256" => "BLAKE2b-256",
491 "BLAKE2B384" => "BLAKE2b-384",
492 "BLAKE2B512" => "BLAKE2b-512",
493 "BLAKE3" => "BLAKE3",
494 "ADLER32" => "ADLER-32",
495 _ => return name.to_string(),
496 }
497 .to_string()
498}
499
500pub fn hash_algorithm_strength(name: &str) -> Option<u8> {
522 let canonical = canonical_algorithm_name(name);
523 match canonical.as_str() {
524 "ADLER-32" => Some(0),
525 "MD2" | "MD4" | "MD5" => Some(1),
526 "SHA-1" => Some(2),
527 "SHA-224" => Some(3),
528 "SHA-256" | "SHA3-256" | "BLAKE2b-256" | "BLAKE3" | "MD6" => Some(4),
529 "SHA-384" | "SHA3-384" | "BLAKE2b-384" => Some(5),
530 "SHA-512" | "SHA3-512" | "BLAKE2b-512" => Some(6),
531 _ => None,
532 }
533}
534
535pub fn is_hash_algorithm_downgrade(
560 old_hashes: &BTreeMap<String, String>,
561 new_hashes: &BTreeMap<String, String>,
562) -> bool {
563 if old_hashes.is_empty() || new_hashes.is_empty() {
564 return false;
565 }
566
567 let old_max = old_hashes
568 .keys()
569 .filter_map(|k| hash_algorithm_strength(k))
570 .max();
571 let new_max = new_hashes
572 .keys()
573 .filter_map(|k| hash_algorithm_strength(k))
574 .max();
575
576 match (old_max, new_max) {
577 (Some(old_strength), Some(new_strength)) => new_strength < old_strength,
578 _ => false,
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn test_component_id_purl() {
588 let purl = "pkg:npm/left-pad@1.3.0";
589 let id = ComponentId::new(Some(purl), &[]);
590 assert_eq!(id.as_str(), purl);
591 }
592
593 #[test]
594 fn test_component_id_hash_stability() {
595 let props = [("name", "foo"), ("version", "1.0")];
596 let id1 = ComponentId::new(None, &props);
597 let id2 = ComponentId::new(None, &props);
598 assert_eq!(id1, id2);
599 assert!(id1.as_str().starts_with("h:"));
600 }
601
602 #[test]
603 fn test_normalization() {
604 let mut comp = Component::new("test".to_string(), Some("1.0".to_string()));
605 comp.licenses.insert("MIT".to_string());
606 comp.licenses.insert("Apache-2.0".to_string());
607 comp.hashes.insert("SHA-256".to_string(), "ABC".to_string());
608
609 comp.normalize();
610
611 assert_eq!(
613 comp.licenses,
614 BTreeSet::from(["Apache-2.0".to_string(), "MIT".to_string()])
615 );
616 assert_eq!(comp.hashes.get("sha-256").unwrap(), "abc");
617 }
618
619 #[test]
620 fn test_parse_license_expression() {
621 let ids = parse_license_expression("MIT OR Apache-2.0");
623 assert!(ids.contains("MIT"));
624 assert!(ids.contains("Apache-2.0"));
625 assert_eq!(ids.len(), 2);
626
627 let ids = parse_license_expression("MIT");
629 assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
630
631 let ids = parse_license_expression("MIT AND Apache-2.0");
633 assert!(ids.contains("MIT"));
634 assert!(ids.contains("Apache-2.0"));
635
636 let ids = parse_license_expression("Custom License");
638 assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
639
640 let ids = parse_license_expression("LicenseRef-proprietary");
642 assert_eq!(ids, BTreeSet::from(["LicenseRef-proprietary".to_string()]));
643 }
644
645 #[test]
646 fn test_parse_license_expression_licenseref_and_spdx() {
647 let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
649 assert!(ids.contains("LicenseRef-proprietary"));
650 assert!(ids.contains("Apache-2.0"));
651 assert_eq!(ids.len(), 2);
652 }
653
654 #[test]
655 fn test_parse_license_expression_licenseref_or_spdx() {
656 let ids = parse_license_expression("LicenseRef-custom OR MIT");
658 assert!(ids.contains("LicenseRef-custom"));
659 assert!(ids.contains("MIT"));
660 assert_eq!(ids.len(), 2);
661 }
662
663 #[test]
664 fn test_parse_license_expression_multiple_licenserefs() {
665 let ids = parse_license_expression("LicenseRef-a AND LicenseRef-b");
667 assert!(ids.contains("LicenseRef-a"));
668 assert!(ids.contains("LicenseRef-b"));
669 assert_eq!(ids.len(), 2);
670 }
671
672 #[test]
673 fn test_parse_license_expression_complex_mixed() {
674 let ids = parse_license_expression("(MIT OR LicenseRef-custom) AND Apache-2.0");
676 assert!(ids.contains("MIT"));
677 assert!(ids.contains("LicenseRef-custom"));
678 assert!(ids.contains("Apache-2.0"));
679 assert_eq!(ids.len(), 3);
680 }
681
682 #[test]
683 fn test_parse_license_expression_documentref() {
684 let ids = parse_license_expression("DocumentRef-ext:LicenseRef-custom");
686 assert_eq!(
687 ids,
688 BTreeSet::from(["DocumentRef-ext:LicenseRef-custom".to_string()])
689 );
690 }
691
692 #[test]
693 fn test_license_set_equality() {
694 let mut c1 = Component::new("test".into(), None);
696 c1.licenses.insert("MIT".into());
697 c1.licenses.insert("Apache-2.0".into());
698
699 let mut c2 = Component::new("test".into(), None);
700 c2.licenses.insert("Apache-2.0".into());
701 c2.licenses.insert("MIT".into());
702
703 assert_eq!(c1.licenses, c2.licenses);
704 }
705
706 #[test]
707 fn test_query_api() {
708 let mut sbom = Sbom::default();
709 let c1 = Component::new("a".into(), Some("1".into()));
710 let c2 = Component::new("b".into(), Some("1".into()));
711 let c3 = Component::new("c".into(), Some("1".into()));
712
713 let id1 = c1.id.clone();
714 let id2 = c2.id.clone();
715 let id3 = c3.id.clone();
716
717 sbom.components.insert(id1.clone(), c1);
718 sbom.components.insert(id2.clone(), c2);
719 sbom.components.insert(id3.clone(), c3);
720
721 sbom.dependencies
723 .entry(id1.clone())
724 .or_default()
725 .insert(id2.clone(), DependencyKind::Runtime);
726 sbom.dependencies
727 .entry(id2.clone())
728 .or_default()
729 .insert(id3.clone(), DependencyKind::Runtime);
730
731 assert_eq!(sbom.roots(), vec![id1.clone()]);
732 assert_eq!(sbom.deps(&id1), vec![id2.clone()]);
733 assert_eq!(sbom.rdeps(&id2), vec![id1.clone()]);
734
735 let transitive = sbom.transitive_deps(&id1);
736 assert!(transitive.contains(&id2));
737 assert!(transitive.contains(&id3));
738 assert_eq!(transitive.len(), 2);
739
740 assert_eq!(sbom.missing_hashes().len(), 3);
741 }
742
743 #[test]
744 fn test_ecosystems_query() {
745 let mut sbom = Sbom::default();
746
747 let mut c1 = Component::new("lodash".into(), Some("1.0".into()));
748 c1.ecosystem = Some("npm".into());
749 let mut c2 = Component::new("serde".into(), Some("1.0".into()));
750 c2.ecosystem = Some("cargo".into());
751 let mut c3 = Component::new("other-npm".into(), Some("1.0".into()));
752 c3.ecosystem = Some("npm".into());
753 let c4 = Component::new("no-ecosystem".into(), Some("1.0".into()));
754
755 sbom.components.insert(c1.id.clone(), c1);
756 sbom.components.insert(c2.id.clone(), c2);
757 sbom.components.insert(c3.id.clone(), c3);
758 sbom.components.insert(c4.id.clone(), c4);
759
760 let ecosystems = sbom.ecosystems();
761 assert_eq!(ecosystems.len(), 2);
762 assert!(ecosystems.contains("npm"));
763 assert!(ecosystems.contains("cargo"));
764 }
765
766 #[test]
767 fn test_licenses_query() {
768 let mut sbom = Sbom::default();
769
770 let mut c1 = Component::new("a".into(), Some("1.0".into()));
771 c1.licenses.insert("MIT".into());
772 c1.licenses.insert("Apache-2.0".into());
773 let mut c2 = Component::new("b".into(), Some("1.0".into()));
774 c2.licenses.insert("MIT".into());
775 c2.licenses.insert("GPL-3.0-only".into());
776 let c3 = Component::new("c".into(), Some("1.0".into()));
777
778 sbom.components.insert(c1.id.clone(), c1);
779 sbom.components.insert(c2.id.clone(), c2);
780 sbom.components.insert(c3.id.clone(), c3);
781
782 let licenses = sbom.licenses();
783 assert_eq!(licenses.len(), 3);
784 assert!(licenses.contains("MIT"));
785 assert!(licenses.contains("Apache-2.0"));
786 assert!(licenses.contains("GPL-3.0-only"));
787 }
788
789 #[test]
790 fn test_by_purl() {
791 let mut sbom = Sbom::default();
792
793 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
794 c1.purl = Some("pkg:npm/lodash@4.17.21".into());
795 c1.id = ComponentId::new(c1.purl.as_deref(), &[]);
796 let c2 = Component::new("no-purl".into(), Some("1.0".into()));
797
798 sbom.components.insert(c1.id.clone(), c1);
799 sbom.components.insert(c2.id.clone(), c2);
800
801 let found = sbom.by_purl("pkg:npm/lodash@4.17.21");
802 assert!(found.is_some());
803 assert_eq!(found.unwrap().name, "lodash");
804
805 assert!(sbom.by_purl("pkg:npm/nonexistent@1.0").is_none());
806 }
807
808 #[test]
809 fn test_component_id_unparseable_purl() {
810 let id = ComponentId::new(Some("not-a-valid-purl-but-still-a-string"), &[]);
812 assert_eq!(id.as_str(), "not-a-valid-purl-but-still-a-string");
813 }
814
815 #[test]
816 fn test_component_id_display() {
817 let id = ComponentId::new(Some("pkg:npm/foo@1.0"), &[]);
818 assert_eq!(format!("{}", id), "pkg:npm/foo@1.0");
819 }
820
821 #[test]
822 fn test_sbom_normalize_clears_metadata() {
823 let mut sbom = Sbom::default();
824 sbom.metadata.timestamp = Some("2024-01-01T00:00:00Z".into());
825 sbom.metadata.tools.push("syft".into());
826 sbom.metadata.authors.push("alice".into());
827
828 let c = Component::new("a".into(), Some("1".into()));
829 sbom.components.insert(c.id.clone(), c);
830
831 sbom.normalize();
832
833 assert!(sbom.metadata.timestamp.is_none());
834 assert!(sbom.metadata.tools.is_empty());
835 assert!(sbom.metadata.authors.is_empty());
836 }
837
838 #[test]
839 fn test_missing_hashes_mixed() {
840 let mut sbom = Sbom::default();
841
842 let c1 = Component::new("no-hash".into(), Some("1.0".into()));
843 let mut c2 = Component::new("has-hash".into(), Some("1.0".into()));
844 c2.hashes.insert("sha256".into(), "abc".into());
845
846 sbom.components.insert(c1.id.clone(), c1);
847 sbom.components.insert(c2.id.clone(), c2);
848
849 let missing = sbom.missing_hashes();
850 assert_eq!(missing.len(), 1);
851 }
852
853 #[test]
854 fn test_ecosystem_from_purl() {
855 use super::ecosystem_from_purl;
856
857 assert_eq!(
858 ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
859 Some("npm".to_string())
860 );
861 assert_eq!(
862 ecosystem_from_purl("pkg:cargo/serde@1.0.0"),
863 Some("cargo".to_string())
864 );
865 assert_eq!(
866 ecosystem_from_purl("pkg:pypi/requests@2.28.0"),
867 Some("pypi".to_string())
868 );
869 assert_eq!(
870 ecosystem_from_purl("pkg:maven/org.apache/commons@1.0"),
871 Some("maven".to_string())
872 );
873 assert_eq!(ecosystem_from_purl("invalid-purl"), None);
874 assert_eq!(ecosystem_from_purl(""), None);
875 }
876
877 #[test]
878 fn test_canonical_algorithm_name() {
879 assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
881 assert_eq!(canonical_algorithm_name("SHA1"), "SHA-1");
882 assert_eq!(canonical_algorithm_name("SHA384"), "SHA-384");
883 assert_eq!(canonical_algorithm_name("SHA512"), "SHA-512");
884 assert_eq!(canonical_algorithm_name("SHA224"), "SHA-224");
885
886 assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
888 assert_eq!(canonical_algorithm_name("SHA-1"), "SHA-1");
889 assert_eq!(canonical_algorithm_name("SHA-384"), "SHA-384");
890
891 assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
893 assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
894
895 assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
897 assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
898
899 assert_eq!(canonical_algorithm_name("MD5"), "MD5");
901 assert_eq!(canonical_algorithm_name("md5"), "MD5");
902
903 assert_eq!(canonical_algorithm_name("BLAKE2b-256"), "BLAKE2b-256");
905 assert_eq!(canonical_algorithm_name("BLAKE2B256"), "BLAKE2b-256");
906 assert_eq!(canonical_algorithm_name("BLAKE3"), "BLAKE3");
907
908 assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
910 assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
911
912 assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
914 }
915
916 #[test]
917 fn test_hash_algorithm_strength_ordering() {
918 let md5 = hash_algorithm_strength("MD5").unwrap();
920 let sha1 = hash_algorithm_strength("SHA-1").unwrap();
921 let sha224 = hash_algorithm_strength("SHA-224").unwrap();
922 let sha256 = hash_algorithm_strength("SHA-256").unwrap();
923 let sha384 = hash_algorithm_strength("SHA-384").unwrap();
924 let sha512 = hash_algorithm_strength("SHA-512").unwrap();
925
926 assert!(md5 < sha1);
927 assert!(sha1 < sha224);
928 assert!(sha224 < sha256);
929 assert!(sha256 < sha384);
930 assert!(sha384 < sha512);
931 }
932
933 #[test]
934 fn test_hash_algorithm_strength_variants() {
935 assert_eq!(
937 hash_algorithm_strength("sha256"),
938 hash_algorithm_strength("SHA-256")
939 );
940 assert_eq!(
941 hash_algorithm_strength("sha-1"),
942 hash_algorithm_strength("SHA1")
943 );
944
945 assert_eq!(
947 hash_algorithm_strength("SHA3-256"),
948 hash_algorithm_strength("SHA-256")
949 );
950 assert_eq!(
951 hash_algorithm_strength("SHA3-512"),
952 hash_algorithm_strength("SHA-512")
953 );
954
955 assert_eq!(
957 hash_algorithm_strength("BLAKE2b-256"),
958 hash_algorithm_strength("SHA-256")
959 );
960 assert_eq!(
961 hash_algorithm_strength("BLAKE3"),
962 hash_algorithm_strength("SHA-256")
963 );
964
965 assert_eq!(hash_algorithm_strength("TIGER"), None);
967 assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
968 }
969
970 #[test]
971 fn test_hash_algorithm_strength_adler() {
972 let adler = hash_algorithm_strength("ADLER-32").unwrap();
973 let md5 = hash_algorithm_strength("MD5").unwrap();
974 assert!(adler < md5);
975 }
976
977 #[test]
978 fn test_is_hash_algorithm_downgrade_sha256_to_md5() {
979 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
980 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
981 assert!(is_hash_algorithm_downgrade(&old, &new));
982 }
983
984 #[test]
985 fn test_is_hash_algorithm_downgrade_upgrade_not_flagged() {
986 let old: BTreeMap<String, String> = [("sha-1".into(), "abc".into())].into();
987 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
988 assert!(!is_hash_algorithm_downgrade(&old, &new));
989 }
990
991 #[test]
992 fn test_is_hash_algorithm_downgrade_same_algorithm() {
993 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
994 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
995 assert!(!is_hash_algorithm_downgrade(&old, &new));
996 }
997
998 #[test]
999 fn test_is_hash_algorithm_downgrade_empty_old() {
1000 let old: BTreeMap<String, String> = BTreeMap::new();
1001 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1002 assert!(!is_hash_algorithm_downgrade(&old, &new));
1003 }
1004
1005 #[test]
1006 fn test_is_hash_algorithm_downgrade_empty_new() {
1007 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1008 let new: BTreeMap<String, String> = BTreeMap::new();
1009 assert!(!is_hash_algorithm_downgrade(&old, &new));
1010 }
1011
1012 #[test]
1013 fn test_is_hash_algorithm_downgrade_multi_algorithm() {
1014 let old: BTreeMap<String, String> = [
1016 ("sha-256".into(), "abc".into()),
1017 ("md5".into(), "xyz".into()),
1018 ]
1019 .into();
1020 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1021 assert!(is_hash_algorithm_downgrade(&old, &new));
1022 }
1023
1024 #[test]
1025 fn test_is_hash_algorithm_downgrade_multi_algorithm_kept() {
1026 let old: BTreeMap<String, String> = [
1028 ("sha-256".into(), "abc".into()),
1029 ("md5".into(), "xyz".into()),
1030 ]
1031 .into();
1032 let new: BTreeMap<String, String> = [
1033 ("sha-256".into(), "def".into()),
1034 ("sha-1".into(), "ghi".into()),
1035 ]
1036 .into();
1037 assert!(!is_hash_algorithm_downgrade(&old, &new));
1038 }
1039
1040 #[test]
1041 fn test_detect_cycles_none() {
1042 let mut sbom = Sbom::default();
1043 let c1 = Component::new("a".into(), Some("1".into()));
1044 let c2 = Component::new("b".into(), Some("1".into()));
1045 let c3 = Component::new("c".into(), Some("1".into()));
1046
1047 let id1 = c1.id.clone();
1048 let id2 = c2.id.clone();
1049 let id3 = c3.id.clone();
1050
1051 sbom.components.insert(id1.clone(), c1);
1052 sbom.components.insert(id2.clone(), c2);
1053 sbom.components.insert(id3.clone(), c3);
1054
1055 sbom.dependencies
1057 .entry(id1.clone())
1058 .or_default()
1059 .insert(id2.clone(), DependencyKind::Runtime);
1060 sbom.dependencies
1061 .entry(id2.clone())
1062 .or_default()
1063 .insert(id3.clone(), DependencyKind::Runtime);
1064
1065 assert!(sbom.detect_cycles().is_empty());
1066 }
1067
1068 #[test]
1069 fn test_detect_cycles_simple() {
1070 let mut sbom = Sbom::default();
1071 let c1 = Component::new("a".into(), Some("1".into()));
1072 let c2 = Component::new("b".into(), Some("1".into()));
1073
1074 let id1 = c1.id.clone();
1075 let id2 = c2.id.clone();
1076
1077 sbom.components.insert(id1.clone(), c1);
1078 sbom.components.insert(id2.clone(), c2);
1079
1080 sbom.dependencies
1082 .entry(id1.clone())
1083 .or_default()
1084 .insert(id2.clone(), DependencyKind::Runtime);
1085 sbom.dependencies
1086 .entry(id2.clone())
1087 .or_default()
1088 .insert(id1.clone(), DependencyKind::Runtime);
1089
1090 let cycles = sbom.detect_cycles();
1091 assert_eq!(cycles.len(), 1);
1092 assert_eq!(cycles[0].first(), cycles[0].last());
1094 }
1095
1096 #[test]
1097 fn test_detect_cycles_self_loop() {
1098 let mut sbom = Sbom::default();
1099 let c1 = Component::new("a".into(), Some("1".into()));
1100 let id1 = c1.id.clone();
1101 sbom.components.insert(id1.clone(), c1);
1102
1103 sbom.dependencies
1105 .entry(id1.clone())
1106 .or_default()
1107 .insert(id1.clone(), DependencyKind::Runtime);
1108
1109 let cycles = sbom.detect_cycles();
1110 assert_eq!(cycles.len(), 1);
1111 assert_eq!(cycles[0].len(), 2); }
1113
1114 #[test]
1115 fn test_detect_cycles_empty_graph() {
1116 let sbom = Sbom::default();
1117 assert!(sbom.detect_cycles().is_empty());
1118 }
1119
1120 #[test]
1121 fn test_detect_cycles_three_node() {
1122 let mut sbom = Sbom::default();
1123 let c1 = Component::new("a".into(), Some("1".into()));
1124 let c2 = Component::new("b".into(), Some("1".into()));
1125 let c3 = Component::new("c".into(), Some("1".into()));
1126
1127 let id1 = c1.id.clone();
1128 let id2 = c2.id.clone();
1129 let id3 = c3.id.clone();
1130
1131 sbom.components.insert(id1.clone(), c1);
1132 sbom.components.insert(id2.clone(), c2);
1133 sbom.components.insert(id3.clone(), c3);
1134
1135 sbom.dependencies
1137 .entry(id1.clone())
1138 .or_default()
1139 .insert(id2.clone(), DependencyKind::Runtime);
1140 sbom.dependencies
1141 .entry(id2.clone())
1142 .or_default()
1143 .insert(id3.clone(), DependencyKind::Runtime);
1144 sbom.dependencies
1145 .entry(id3.clone())
1146 .or_default()
1147 .insert(id1.clone(), DependencyKind::Runtime);
1148
1149 let cycles = sbom.detect_cycles();
1150 assert_eq!(cycles.len(), 1);
1151 assert_eq!(cycles[0].first(), cycles[0].last());
1152 assert_eq!(cycles[0].len(), 4); }
1154
1155 #[test]
1156 fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
1157 let old: BTreeMap<String, String> = [("TIGER".into(), "abc".into())].into();
1159 let new: BTreeMap<String, String> = [("WHIRLPOOL".into(), "def".into())].into();
1160 assert!(!is_hash_algorithm_downgrade(&old, &new));
1161 }
1162}