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, 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(skip)]
40 pub reverse_deps: BTreeMap<ComponentId, BTreeSet<ComponentId>>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub warnings: Vec<String>,
44}
45
46impl PartialEq for Sbom {
47 fn eq(&self, other: &Self) -> bool {
48 self.metadata == other.metadata
49 && self.components == other.components
50 && self.dependencies == other.dependencies
51 && self.warnings == other.warnings
52 }
53}
54
55impl Eq for Sbom {}
56
57impl Default for Sbom {
58 fn default() -> Self {
59 Self {
60 metadata: Metadata::default(),
61 components: IndexMap::new(),
62 dependencies: BTreeMap::new(),
63 reverse_deps: BTreeMap::new(),
64 warnings: Vec::new(),
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
75pub struct Metadata {
76 pub timestamp: Option<String>,
78 pub tools: Vec<String>,
80 pub authors: Vec<String>,
82}
83
84#[derive(
94 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
95)]
96#[serde(rename_all = "lowercase")]
97pub enum DependencyKind {
98 #[default]
100 Runtime,
101 Dev,
103 Build,
105 Test,
107 Optional,
109 Provided,
111}
112
113impl fmt::Display for DependencyKind {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 match self {
116 Self::Runtime => write!(f, "runtime"),
117 Self::Dev => write!(f, "dev"),
118 Self::Build => write!(f, "build"),
119 Self::Test => write!(f, "test"),
120 Self::Optional => write!(f, "optional"),
121 Self::Provided => write!(f, "provided"),
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
146pub struct ComponentId(String);
147
148impl ComponentId {
149 pub fn new(purl: Option<&str>, properties: &[(&str, &str)]) -> Self {
154 if let Some(purl) = purl {
155 if let Ok(parsed) = PackageUrl::from_str(purl) {
156 return ComponentId(parsed.to_string());
157 }
158 return ComponentId(purl.to_string());
159 }
160
161 let mut hasher = Sha256::new();
163 for (k, v) in properties {
164 hasher.update(k.as_bytes());
165 hasher.update(b":");
166 hasher.update(v.as_bytes());
167 hasher.update(b"|");
168 }
169 let hash = hex::encode(hasher.finalize());
170 ComponentId(format!("h:{}", hash))
171 }
172
173 pub fn as_str(&self) -> &str {
175 &self.0
176 }
177}
178
179impl std::fmt::Display for ComponentId {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 write!(f, "{}", self.0)
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Component {
192 pub id: ComponentId,
194 pub name: String,
196 pub version: Option<String>,
198 pub ecosystem: Option<String>,
200 pub supplier: Option<String>,
202 pub description: Option<String>,
204 pub purl: Option<String>,
206 pub licenses: BTreeSet<String>,
208 pub hashes: BTreeMap<String, String>,
210 pub source_ids: Vec<String>,
212}
213
214impl Component {
215 pub fn new(name: String, version: Option<String>) -> Self {
220 let mut props = vec![("name", name.as_str())];
221 if let Some(v) = &version {
222 props.push(("version", v));
223 }
224 let id = ComponentId::new(None, &props);
225
226 Self {
227 id,
228 name,
229 version,
230 ecosystem: None,
231 supplier: None,
232 description: None,
233 purl: None,
234 licenses: BTreeSet::new(),
235 hashes: BTreeMap::new(),
236 source_ids: Vec::new(),
237 }
238 }
239}
240
241impl Sbom {
242 pub fn normalize(&mut self) {
252 self.components.sort_keys();
254
255 for component in self.components.values_mut() {
257 component.normalize();
258 }
259
260 self.metadata.timestamp = None;
262 self.metadata.tools.clear();
263 self.metadata.authors.clear();
264
265 self.rebuild_reverse_deps();
266 }
267
268 pub fn rebuild_reverse_deps(&mut self) {
274 self.reverse_deps.clear();
275 for (parent, children) in &self.dependencies {
276 for child in children.keys() {
277 self.reverse_deps
278 .entry(child.clone())
279 .or_default()
280 .insert(parent.clone());
281 }
282 }
283 }
284
285 pub fn roots(&self) -> Vec<ComponentId> {
290 self.components
291 .keys()
292 .filter(|id| self.reverse_deps.get(*id).is_none_or(BTreeSet::is_empty))
293 .cloned()
294 .collect()
295 }
296
297 pub fn deps(&self, id: &ComponentId) -> Vec<ComponentId> {
299 self.dependencies
300 .get(id)
301 .map(|d| d.keys().cloned().collect())
302 .unwrap_or_default()
303 }
304
305 pub fn rdeps(&self, id: &ComponentId) -> Vec<ComponentId> {
308 self.reverse_deps
309 .get(id)
310 .map(|parents| parents.iter().cloned().collect())
311 .unwrap_or_default()
312 }
313
314 pub fn transitive_deps(&self, id: &ComponentId) -> BTreeSet<ComponentId> {
318 let mut visited = BTreeSet::new();
319 let mut stack = vec![id.clone()];
320 while let Some(current) = stack.pop() {
321 if let Some(children) = self.dependencies.get(¤t) {
322 for child in children.keys() {
323 if visited.insert(child.clone()) {
324 stack.push(child.clone());
325 }
326 }
327 }
328 }
329 visited
330 }
331
332 pub fn ecosystems(&self) -> BTreeSet<String> {
334 self.components
335 .values()
336 .filter_map(|c| c.ecosystem.clone())
337 .collect()
338 }
339
340 pub fn licenses(&self) -> BTreeSet<String> {
342 self.components
343 .values()
344 .flat_map(|c| c.licenses.iter().cloned())
345 .collect()
346 }
347
348 pub fn missing_hashes(&self) -> Vec<ComponentId> {
352 self.components
353 .iter()
354 .filter(|(_, c)| c.hashes.is_empty())
355 .map(|(id, _)| id.clone())
356 .collect()
357 }
358
359 pub fn by_purl(&self, purl: &str) -> Option<&Component> {
361 let id = ComponentId::new(Some(purl), &[]);
362 self.components.get(&id)
363 }
364
365 pub fn detect_cycles(&self) -> Vec<Vec<ComponentId>> {
374 enum Frame {
375 Enter(ComponentId),
376 Exit(ComponentId),
377 }
378
379 let mut visited = BTreeSet::new();
380 let mut on_stack = BTreeSet::new();
381 let mut path = Vec::new();
382 let mut cycles = Vec::new();
383
384 let mut stack: Vec<Frame> = self
385 .dependencies
386 .keys()
387 .rev()
388 .map(|k| Frame::Enter(k.clone()))
389 .collect();
390
391 while let Some(frame) = stack.pop() {
392 match frame {
393 Frame::Enter(node) => {
394 if visited.contains(&node) {
395 continue;
396 }
397 visited.insert(node.clone());
398 on_stack.insert(node.clone());
399 path.push(node.clone());
400 stack.push(Frame::Exit(node.clone()));
401
402 if let Some(children) = self.dependencies.get(&node) {
403 for child in children.keys().rev() {
404 if !visited.contains(child) {
405 stack.push(Frame::Enter(child.clone()));
406 } else if on_stack.contains(child) {
407 if let Some(start) = path.iter().position(|n| n == child) {
408 let mut cycle: Vec<_> = path[start..].to_vec();
409 cycle.push(child.clone());
410 cycles.push(cycle);
411 }
412 }
413 }
414 }
415 }
416 Frame::Exit(node) => {
417 path.pop();
418 on_stack.remove(&node);
419 }
420 }
421 }
422
423 cycles
424 }
425}
426
427impl Component {
428 pub fn normalize(&mut self) {
433 let normalized_hashes: BTreeMap<String, String> = self
434 .hashes
435 .iter()
436 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
437 .collect();
438 self.hashes = normalized_hashes;
439 }
440}
441
442pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
456 PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
457}
458
459pub fn parse_license_expression(license: &str) -> BTreeSet<String> {
479 match spdx::Expression::parse(license) {
480 Ok(expr) => {
481 let ids: BTreeSet<String> = expr
482 .requirements()
483 .map(|r| match &r.req.license {
484 spdx::LicenseItem::Spdx { id, .. } => id.name.to_string(),
485 other => other.to_string(),
486 })
487 .collect();
488 if ids.is_empty() {
489 BTreeSet::from([license.to_string()])
491 } else {
492 ids
493 }
494 }
495 Err(_) => {
496 BTreeSet::from([license.to_string()])
498 }
499 }
500}
501
502pub fn canonical_algorithm_name(name: &str) -> String {
517 match name.replace('-', "").to_uppercase().as_str() {
518 "MD2" => "MD2",
519 "MD4" => "MD4",
520 "MD5" => "MD5",
521 "MD6" => "MD6",
522 "SHA1" => "SHA-1",
523 "SHA224" => "SHA-224",
524 "SHA256" => "SHA-256",
525 "SHA384" => "SHA-384",
526 "SHA512" => "SHA-512",
527 "SHA3256" => "SHA3-256",
528 "SHA3384" => "SHA3-384",
529 "SHA3512" => "SHA3-512",
530 "BLAKE2B256" => "BLAKE2b-256",
531 "BLAKE2B384" => "BLAKE2b-384",
532 "BLAKE2B512" => "BLAKE2b-512",
533 "BLAKE3" => "BLAKE3",
534 "ADLER32" => "ADLER-32",
535 _ => return name.to_string(),
536 }
537 .to_string()
538}
539
540pub fn hash_algorithm_strength(name: &str) -> Option<u8> {
562 let canonical = canonical_algorithm_name(name);
563 match canonical.as_str() {
564 "ADLER-32" => Some(0),
565 "MD2" | "MD4" | "MD5" => Some(1),
566 "SHA-1" => Some(2),
567 "SHA-224" => Some(3),
568 "SHA-256" | "SHA3-256" | "BLAKE2b-256" | "BLAKE3" | "MD6" => Some(4),
569 "SHA-384" | "SHA3-384" | "BLAKE2b-384" => Some(5),
570 "SHA-512" | "SHA3-512" | "BLAKE2b-512" => Some(6),
571 _ => None,
572 }
573}
574
575pub fn is_hash_algorithm_downgrade(
600 old_hashes: &BTreeMap<String, String>,
601 new_hashes: &BTreeMap<String, String>,
602) -> bool {
603 if old_hashes.is_empty() || new_hashes.is_empty() {
604 return false;
605 }
606
607 let old_max = old_hashes
608 .keys()
609 .filter_map(|k| hash_algorithm_strength(k))
610 .max();
611 let new_max = new_hashes
612 .keys()
613 .filter_map(|k| hash_algorithm_strength(k))
614 .max();
615
616 match (old_max, new_max) {
617 (Some(old_strength), Some(new_strength)) => new_strength < old_strength,
618 _ => false,
619 }
620}
621
622pub fn is_copyleft_license(id: &str) -> bool {
642 spdx::license_id(id)
643 .map(|l| l.is_copyleft())
644 .unwrap_or(false)
645}
646
647pub fn copyleft_introduced(old: &BTreeSet<String>, new: &BTreeSet<String>) -> bool {
668 new.iter()
669 .any(|id| is_copyleft_license(id) && !old.contains(id))
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn test_component_id_purl() {
678 let purl = "pkg:npm/left-pad@1.3.0";
679 let id = ComponentId::new(Some(purl), &[]);
680 assert_eq!(id.as_str(), purl);
681 }
682
683 #[test]
684 fn test_component_id_hash_stability() {
685 let props = [("name", "foo"), ("version", "1.0")];
686 let id1 = ComponentId::new(None, &props);
687 let id2 = ComponentId::new(None, &props);
688 assert_eq!(id1, id2);
689 assert!(id1.as_str().starts_with("h:"));
690 }
691
692 #[test]
693 fn test_normalization() {
694 let mut comp = Component::new("test".to_string(), Some("1.0".to_string()));
695 comp.licenses.insert("MIT".to_string());
696 comp.licenses.insert("Apache-2.0".to_string());
697 comp.hashes.insert("SHA-256".to_string(), "ABC".to_string());
698
699 comp.normalize();
700
701 assert_eq!(
702 comp.licenses,
703 BTreeSet::from(["Apache-2.0".to_string(), "MIT".to_string()])
704 );
705 assert_eq!(comp.hashes.get("sha-256").unwrap(), "abc");
706 }
707
708 #[test]
709 fn test_parse_license_expression() {
710 let ids = parse_license_expression("MIT OR Apache-2.0");
712 assert!(ids.contains("MIT"));
713 assert!(ids.contains("Apache-2.0"));
714 assert_eq!(ids.len(), 2);
715
716 let ids = parse_license_expression("MIT");
718 assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
719
720 let ids = parse_license_expression("MIT AND Apache-2.0");
722 assert!(ids.contains("MIT"));
723 assert!(ids.contains("Apache-2.0"));
724
725 let ids = parse_license_expression("Custom License");
727 assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
728
729 let ids = parse_license_expression("LicenseRef-proprietary");
731 assert_eq!(ids, BTreeSet::from(["LicenseRef-proprietary".to_string()]));
732 }
733
734 #[test]
735 fn test_parse_license_expression_licenseref_and_spdx() {
736 let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
738 assert!(ids.contains("LicenseRef-proprietary"));
739 assert!(ids.contains("Apache-2.0"));
740 assert_eq!(ids.len(), 2);
741 }
742
743 #[test]
744 fn test_parse_license_expression_licenseref_or_spdx() {
745 let ids = parse_license_expression("LicenseRef-custom OR MIT");
747 assert!(ids.contains("LicenseRef-custom"));
748 assert!(ids.contains("MIT"));
749 assert_eq!(ids.len(), 2);
750 }
751
752 #[test]
753 fn test_parse_license_expression_multiple_licenserefs() {
754 let ids = parse_license_expression("LicenseRef-a AND LicenseRef-b");
756 assert!(ids.contains("LicenseRef-a"));
757 assert!(ids.contains("LicenseRef-b"));
758 assert_eq!(ids.len(), 2);
759 }
760
761 #[test]
762 fn test_parse_license_expression_complex_mixed() {
763 let ids = parse_license_expression("(MIT OR LicenseRef-custom) AND Apache-2.0");
765 assert!(ids.contains("MIT"));
766 assert!(ids.contains("LicenseRef-custom"));
767 assert!(ids.contains("Apache-2.0"));
768 assert_eq!(ids.len(), 3);
769 }
770
771 #[test]
772 fn test_parse_license_expression_documentref() {
773 let ids = parse_license_expression("DocumentRef-ext:LicenseRef-custom");
775 assert_eq!(
776 ids,
777 BTreeSet::from(["DocumentRef-ext:LicenseRef-custom".to_string()])
778 );
779 }
780
781 #[test]
782 fn test_license_set_equality() {
783 let mut c1 = Component::new("test".into(), None);
785 c1.licenses.insert("MIT".into());
786 c1.licenses.insert("Apache-2.0".into());
787
788 let mut c2 = Component::new("test".into(), None);
789 c2.licenses.insert("Apache-2.0".into());
790 c2.licenses.insert("MIT".into());
791
792 assert_eq!(c1.licenses, c2.licenses);
793 }
794
795 #[test]
796 fn test_query_api() {
797 let mut sbom = Sbom::default();
798 let c1 = Component::new("a".into(), Some("1".into()));
799 let c2 = Component::new("b".into(), Some("1".into()));
800 let c3 = Component::new("c".into(), Some("1".into()));
801
802 let id1 = c1.id.clone();
803 let id2 = c2.id.clone();
804 let id3 = c3.id.clone();
805
806 sbom.components.insert(id1.clone(), c1);
807 sbom.components.insert(id2.clone(), c2);
808 sbom.components.insert(id3.clone(), c3);
809
810 sbom.dependencies
812 .entry(id1.clone())
813 .or_default()
814 .insert(id2.clone(), DependencyKind::Runtime);
815 sbom.dependencies
816 .entry(id2.clone())
817 .or_default()
818 .insert(id3.clone(), DependencyKind::Runtime);
819 sbom.rebuild_reverse_deps();
820
821 assert_eq!(sbom.roots(), vec![id1.clone()]);
822 assert_eq!(sbom.deps(&id1), vec![id2.clone()]);
823 assert_eq!(sbom.rdeps(&id2), vec![id1.clone()]);
824
825 let transitive = sbom.transitive_deps(&id1);
826 assert!(transitive.contains(&id2));
827 assert!(transitive.contains(&id3));
828 assert_eq!(transitive.len(), 2);
829
830 assert_eq!(sbom.missing_hashes().len(), 3);
831 }
832
833 #[test]
834 fn test_ecosystems_query() {
835 let mut sbom = Sbom::default();
836
837 let mut c1 = Component::new("lodash".into(), Some("1.0".into()));
838 c1.ecosystem = Some("npm".into());
839 let mut c2 = Component::new("serde".into(), Some("1.0".into()));
840 c2.ecosystem = Some("cargo".into());
841 let mut c3 = Component::new("other-npm".into(), Some("1.0".into()));
842 c3.ecosystem = Some("npm".into());
843 let c4 = Component::new("no-ecosystem".into(), Some("1.0".into()));
844
845 sbom.components.insert(c1.id.clone(), c1);
846 sbom.components.insert(c2.id.clone(), c2);
847 sbom.components.insert(c3.id.clone(), c3);
848 sbom.components.insert(c4.id.clone(), c4);
849
850 let ecosystems = sbom.ecosystems();
851 assert_eq!(ecosystems.len(), 2);
852 assert!(ecosystems.contains("npm"));
853 assert!(ecosystems.contains("cargo"));
854 }
855
856 #[test]
857 fn test_licenses_query() {
858 let mut sbom = Sbom::default();
859
860 let mut c1 = Component::new("a".into(), Some("1.0".into()));
861 c1.licenses.insert("MIT".into());
862 c1.licenses.insert("Apache-2.0".into());
863 let mut c2 = Component::new("b".into(), Some("1.0".into()));
864 c2.licenses.insert("MIT".into());
865 c2.licenses.insert("GPL-3.0-only".into());
866 let c3 = Component::new("c".into(), Some("1.0".into()));
867
868 sbom.components.insert(c1.id.clone(), c1);
869 sbom.components.insert(c2.id.clone(), c2);
870 sbom.components.insert(c3.id.clone(), c3);
871
872 let licenses = sbom.licenses();
873 assert_eq!(licenses.len(), 3);
874 assert!(licenses.contains("MIT"));
875 assert!(licenses.contains("Apache-2.0"));
876 assert!(licenses.contains("GPL-3.0-only"));
877 }
878
879 #[test]
880 fn test_by_purl() {
881 let mut sbom = Sbom::default();
882
883 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
884 c1.purl = Some("pkg:npm/lodash@4.17.21".into());
885 c1.id = ComponentId::new(c1.purl.as_deref(), &[]);
886 let c2 = Component::new("no-purl".into(), Some("1.0".into()));
887
888 sbom.components.insert(c1.id.clone(), c1);
889 sbom.components.insert(c2.id.clone(), c2);
890
891 let found = sbom.by_purl("pkg:npm/lodash@4.17.21");
892 assert!(found.is_some());
893 assert_eq!(found.unwrap().name, "lodash");
894
895 assert!(sbom.by_purl("pkg:npm/nonexistent@1.0").is_none());
896 }
897
898 #[test]
899 fn test_component_id_unparseable_purl() {
900 let id = ComponentId::new(Some("not-a-valid-purl-but-still-a-string"), &[]);
902 assert_eq!(id.as_str(), "not-a-valid-purl-but-still-a-string");
903 }
904
905 #[test]
906 fn test_component_id_display() {
907 let id = ComponentId::new(Some("pkg:npm/foo@1.0"), &[]);
908 assert_eq!(format!("{}", id), "pkg:npm/foo@1.0");
909 }
910
911 #[test]
912 fn test_sbom_normalize_clears_metadata() {
913 let mut sbom = Sbom::default();
914 sbom.metadata.timestamp = Some("2024-01-01T00:00:00Z".into());
915 sbom.metadata.tools.push("syft".into());
916 sbom.metadata.authors.push("alice".into());
917
918 let c = Component::new("a".into(), Some("1".into()));
919 sbom.components.insert(c.id.clone(), c);
920
921 sbom.normalize();
922
923 assert!(sbom.metadata.timestamp.is_none());
924 assert!(sbom.metadata.tools.is_empty());
925 assert!(sbom.metadata.authors.is_empty());
926 }
927
928 #[test]
929 fn test_missing_hashes_mixed() {
930 let mut sbom = Sbom::default();
931
932 let c1 = Component::new("no-hash".into(), Some("1.0".into()));
933 let mut c2 = Component::new("has-hash".into(), Some("1.0".into()));
934 c2.hashes.insert("sha256".into(), "abc".into());
935
936 sbom.components.insert(c1.id.clone(), c1);
937 sbom.components.insert(c2.id.clone(), c2);
938
939 let missing = sbom.missing_hashes();
940 assert_eq!(missing.len(), 1);
941 }
942
943 #[test]
944 fn test_ecosystem_from_purl() {
945 use super::ecosystem_from_purl;
946
947 assert_eq!(
948 ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
949 Some("npm".to_string())
950 );
951 assert_eq!(
952 ecosystem_from_purl("pkg:cargo/serde@1.0.0"),
953 Some("cargo".to_string())
954 );
955 assert_eq!(
956 ecosystem_from_purl("pkg:pypi/requests@2.28.0"),
957 Some("pypi".to_string())
958 );
959 assert_eq!(
960 ecosystem_from_purl("pkg:maven/org.apache/commons@1.0"),
961 Some("maven".to_string())
962 );
963 assert_eq!(ecosystem_from_purl("invalid-purl"), None);
964 assert_eq!(ecosystem_from_purl(""), None);
965 }
966
967 #[test]
968 fn test_canonical_algorithm_name() {
969 assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
971 assert_eq!(canonical_algorithm_name("SHA1"), "SHA-1");
972 assert_eq!(canonical_algorithm_name("SHA384"), "SHA-384");
973 assert_eq!(canonical_algorithm_name("SHA512"), "SHA-512");
974 assert_eq!(canonical_algorithm_name("SHA224"), "SHA-224");
975
976 assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
978 assert_eq!(canonical_algorithm_name("SHA-1"), "SHA-1");
979 assert_eq!(canonical_algorithm_name("SHA-384"), "SHA-384");
980
981 assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
983 assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
984
985 assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
987 assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
988
989 assert_eq!(canonical_algorithm_name("MD5"), "MD5");
991 assert_eq!(canonical_algorithm_name("md5"), "MD5");
992
993 assert_eq!(canonical_algorithm_name("BLAKE2b-256"), "BLAKE2b-256");
995 assert_eq!(canonical_algorithm_name("BLAKE2B256"), "BLAKE2b-256");
996 assert_eq!(canonical_algorithm_name("BLAKE3"), "BLAKE3");
997
998 assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
1000 assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
1001
1002 assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
1004 }
1005
1006 #[test]
1007 fn test_hash_algorithm_strength_ordering() {
1008 let md5 = hash_algorithm_strength("MD5").unwrap();
1010 let sha1 = hash_algorithm_strength("SHA-1").unwrap();
1011 let sha224 = hash_algorithm_strength("SHA-224").unwrap();
1012 let sha256 = hash_algorithm_strength("SHA-256").unwrap();
1013 let sha384 = hash_algorithm_strength("SHA-384").unwrap();
1014 let sha512 = hash_algorithm_strength("SHA-512").unwrap();
1015
1016 assert!(md5 < sha1);
1017 assert!(sha1 < sha224);
1018 assert!(sha224 < sha256);
1019 assert!(sha256 < sha384);
1020 assert!(sha384 < sha512);
1021 }
1022
1023 #[test]
1024 fn test_hash_algorithm_strength_variants() {
1025 assert_eq!(
1027 hash_algorithm_strength("sha256"),
1028 hash_algorithm_strength("SHA-256")
1029 );
1030 assert_eq!(
1031 hash_algorithm_strength("sha-1"),
1032 hash_algorithm_strength("SHA1")
1033 );
1034
1035 assert_eq!(
1037 hash_algorithm_strength("SHA3-256"),
1038 hash_algorithm_strength("SHA-256")
1039 );
1040 assert_eq!(
1041 hash_algorithm_strength("SHA3-512"),
1042 hash_algorithm_strength("SHA-512")
1043 );
1044
1045 assert_eq!(
1047 hash_algorithm_strength("BLAKE2b-256"),
1048 hash_algorithm_strength("SHA-256")
1049 );
1050 assert_eq!(
1051 hash_algorithm_strength("BLAKE3"),
1052 hash_algorithm_strength("SHA-256")
1053 );
1054
1055 assert_eq!(hash_algorithm_strength("TIGER"), None);
1057 assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
1058 }
1059
1060 #[test]
1061 fn test_hash_algorithm_strength_adler() {
1062 let adler = hash_algorithm_strength("ADLER-32").unwrap();
1063 let md5 = hash_algorithm_strength("MD5").unwrap();
1064 assert!(adler < md5);
1065 }
1066
1067 #[test]
1068 fn test_is_hash_algorithm_downgrade_sha256_to_md5() {
1069 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1070 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1071 assert!(is_hash_algorithm_downgrade(&old, &new));
1072 }
1073
1074 #[test]
1075 fn test_is_hash_algorithm_downgrade_upgrade_not_flagged() {
1076 let old: BTreeMap<String, String> = [("sha-1".into(), "abc".into())].into();
1077 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1078 assert!(!is_hash_algorithm_downgrade(&old, &new));
1079 }
1080
1081 #[test]
1082 fn test_is_hash_algorithm_downgrade_same_algorithm() {
1083 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1084 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1085 assert!(!is_hash_algorithm_downgrade(&old, &new));
1086 }
1087
1088 #[test]
1089 fn test_is_hash_algorithm_downgrade_empty_old() {
1090 let old: BTreeMap<String, String> = BTreeMap::new();
1091 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1092 assert!(!is_hash_algorithm_downgrade(&old, &new));
1093 }
1094
1095 #[test]
1096 fn test_is_hash_algorithm_downgrade_empty_new() {
1097 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1098 let new: BTreeMap<String, String> = BTreeMap::new();
1099 assert!(!is_hash_algorithm_downgrade(&old, &new));
1100 }
1101
1102 #[test]
1103 fn test_is_hash_algorithm_downgrade_multi_algorithm() {
1104 let old: BTreeMap<String, String> = [
1106 ("sha-256".into(), "abc".into()),
1107 ("md5".into(), "xyz".into()),
1108 ]
1109 .into();
1110 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1111 assert!(is_hash_algorithm_downgrade(&old, &new));
1112 }
1113
1114 #[test]
1115 fn test_is_hash_algorithm_downgrade_multi_algorithm_kept() {
1116 let old: BTreeMap<String, String> = [
1118 ("sha-256".into(), "abc".into()),
1119 ("md5".into(), "xyz".into()),
1120 ]
1121 .into();
1122 let new: BTreeMap<String, String> = [
1123 ("sha-256".into(), "def".into()),
1124 ("sha-1".into(), "ghi".into()),
1125 ]
1126 .into();
1127 assert!(!is_hash_algorithm_downgrade(&old, &new));
1128 }
1129
1130 #[test]
1131 fn test_detect_cycles_none() {
1132 let mut sbom = Sbom::default();
1133 let c1 = Component::new("a".into(), Some("1".into()));
1134 let c2 = Component::new("b".into(), Some("1".into()));
1135 let c3 = Component::new("c".into(), Some("1".into()));
1136
1137 let id1 = c1.id.clone();
1138 let id2 = c2.id.clone();
1139 let id3 = c3.id.clone();
1140
1141 sbom.components.insert(id1.clone(), c1);
1142 sbom.components.insert(id2.clone(), c2);
1143 sbom.components.insert(id3.clone(), c3);
1144
1145 sbom.dependencies
1147 .entry(id1.clone())
1148 .or_default()
1149 .insert(id2.clone(), DependencyKind::Runtime);
1150 sbom.dependencies
1151 .entry(id2.clone())
1152 .or_default()
1153 .insert(id3.clone(), DependencyKind::Runtime);
1154
1155 assert!(sbom.detect_cycles().is_empty());
1156 }
1157
1158 #[test]
1159 fn test_detect_cycles_simple() {
1160 let mut sbom = Sbom::default();
1161 let c1 = Component::new("a".into(), Some("1".into()));
1162 let c2 = Component::new("b".into(), Some("1".into()));
1163
1164 let id1 = c1.id.clone();
1165 let id2 = c2.id.clone();
1166
1167 sbom.components.insert(id1.clone(), c1);
1168 sbom.components.insert(id2.clone(), c2);
1169
1170 sbom.dependencies
1172 .entry(id1.clone())
1173 .or_default()
1174 .insert(id2.clone(), DependencyKind::Runtime);
1175 sbom.dependencies
1176 .entry(id2.clone())
1177 .or_default()
1178 .insert(id1.clone(), DependencyKind::Runtime);
1179
1180 let cycles = sbom.detect_cycles();
1181 assert_eq!(cycles.len(), 1);
1182 assert_eq!(cycles[0].first(), cycles[0].last());
1184 }
1185
1186 #[test]
1187 fn test_detect_cycles_self_loop() {
1188 let mut sbom = Sbom::default();
1189 let c1 = Component::new("a".into(), Some("1".into()));
1190 let id1 = c1.id.clone();
1191 sbom.components.insert(id1.clone(), c1);
1192
1193 sbom.dependencies
1195 .entry(id1.clone())
1196 .or_default()
1197 .insert(id1.clone(), DependencyKind::Runtime);
1198
1199 let cycles = sbom.detect_cycles();
1200 assert_eq!(cycles.len(), 1);
1201 assert_eq!(cycles[0].len(), 2); }
1203
1204 #[test]
1205 fn test_detect_cycles_empty_graph() {
1206 let sbom = Sbom::default();
1207 assert!(sbom.detect_cycles().is_empty());
1208 }
1209
1210 #[test]
1211 fn test_detect_cycles_three_node() {
1212 let mut sbom = Sbom::default();
1213 let c1 = Component::new("a".into(), Some("1".into()));
1214 let c2 = Component::new("b".into(), Some("1".into()));
1215 let c3 = Component::new("c".into(), Some("1".into()));
1216
1217 let id1 = c1.id.clone();
1218 let id2 = c2.id.clone();
1219 let id3 = c3.id.clone();
1220
1221 sbom.components.insert(id1.clone(), c1);
1222 sbom.components.insert(id2.clone(), c2);
1223 sbom.components.insert(id3.clone(), c3);
1224
1225 sbom.dependencies
1227 .entry(id1.clone())
1228 .or_default()
1229 .insert(id2.clone(), DependencyKind::Runtime);
1230 sbom.dependencies
1231 .entry(id2.clone())
1232 .or_default()
1233 .insert(id3.clone(), DependencyKind::Runtime);
1234 sbom.dependencies
1235 .entry(id3.clone())
1236 .or_default()
1237 .insert(id1.clone(), DependencyKind::Runtime);
1238
1239 let cycles = sbom.detect_cycles();
1240 assert_eq!(cycles.len(), 1);
1241 assert_eq!(cycles[0].first(), cycles[0].last());
1242 assert_eq!(cycles[0].len(), 4); }
1244
1245 #[test]
1246 fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
1247 let old: BTreeMap<String, String> = [("TIGER".into(), "abc".into())].into();
1249 let new: BTreeMap<String, String> = [("WHIRLPOOL".into(), "def".into())].into();
1250 assert!(!is_hash_algorithm_downgrade(&old, &new));
1251 }
1252
1253 #[test]
1254 fn test_is_copyleft_license() {
1255 assert!(is_copyleft_license("GPL-3.0-only"));
1257 assert!(is_copyleft_license("AGPL-3.0-only"));
1258 assert!(is_copyleft_license("LGPL-3.0-only"));
1259 assert!(!is_copyleft_license("MIT"));
1261 assert!(!is_copyleft_license("Apache-2.0"));
1262 assert!(!is_copyleft_license("BSD-3-Clause"));
1263 assert!(!is_copyleft_license("LicenseRef-proprietary"));
1265 assert!(!is_copyleft_license("NOT-A-LICENSE"));
1266 }
1267
1268 #[test]
1269 fn test_copyleft_introduced_permissive_to_copyleft() {
1270 let old: BTreeSet<String> = ["MIT".into()].into();
1271 let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1272 assert!(copyleft_introduced(&old, &new));
1273 }
1274
1275 #[test]
1276 fn test_copyleft_introduced_permissive_to_permissive() {
1277 let old: BTreeSet<String> = ["MIT".into()].into();
1278 let new: BTreeSet<String> = ["Apache-2.0".into()].into();
1279 assert!(!copyleft_introduced(&old, &new));
1280 }
1281
1282 #[test]
1283 fn test_copyleft_introduced_carried_over_not_flagged() {
1284 let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1286 let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1287 assert!(!copyleft_introduced(&old, &new));
1288 }
1289
1290 #[test]
1291 fn test_copyleft_introduced_added_alongside_existing() {
1292 let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1294 let new: BTreeSet<String> = ["GPL-3.0-only".into(), "AGPL-3.0-only".into()].into();
1295 assert!(copyleft_introduced(&old, &new));
1296 }
1297}