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) {
157 return ComponentId(parsed.to_string());
158 }
159 return ComponentId(purl.to_string());
160 }
161
162 let mut hasher = Sha256::new();
164 for (k, v) in properties {
165 hasher.update(k.as_bytes());
166 hasher.update(b":");
167 hasher.update(v.as_bytes());
168 hasher.update(b"|");
169 }
170 let hash = hex::encode(hasher.finalize());
171 ComponentId(format!("h:{}", hash))
172 }
173
174 pub fn as_str(&self) -> &str {
176 &self.0
177 }
178}
179
180impl std::fmt::Display for ComponentId {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 write!(f, "{}", self.0)
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Component {
193 pub id: ComponentId,
195 pub name: String,
197 pub version: Option<String>,
199 pub ecosystem: Option<String>,
201 pub supplier: Option<String>,
203 pub description: Option<String>,
205 pub purl: Option<String>,
207 pub licenses: BTreeSet<String>,
209 pub hashes: BTreeMap<String, String>,
211 pub source_ids: Vec<String>,
213}
214
215impl Component {
216 pub fn new(name: String, version: Option<String>) -> Self {
221 let mut props = vec![("name", name.as_str())];
222 if let Some(v) = &version {
223 props.push(("version", v));
224 }
225 let id = ComponentId::new(None, &props);
226
227 Self {
228 id,
229 name,
230 version,
231 ecosystem: None,
232 supplier: None,
233 description: None,
234 purl: None,
235 licenses: BTreeSet::new(),
236 hashes: BTreeMap::new(),
237 source_ids: Vec::new(),
238 }
239 }
240}
241
242impl Sbom {
243 pub fn normalize(&mut self) {
253 self.components.sort_keys();
255
256 for component in self.components.values_mut() {
258 component.normalize();
259 }
260
261 self.metadata.timestamp = None;
263 self.metadata.tools.clear();
264 self.metadata.authors.clear(); self.rebuild_reverse_deps();
267 }
268
269 pub fn rebuild_reverse_deps(&mut self) {
275 self.reverse_deps.clear();
276 for (parent, children) in &self.dependencies {
277 for child in children.keys() {
278 self.reverse_deps
279 .entry(child.clone())
280 .or_default()
281 .insert(parent.clone());
282 }
283 }
284 }
285
286 pub fn roots(&self) -> Vec<ComponentId> {
291 self.components
292 .keys()
293 .filter(|id| self.reverse_deps.get(*id).is_none_or(BTreeSet::is_empty))
294 .cloned()
295 .collect()
296 }
297
298 pub fn deps(&self, id: &ComponentId) -> Vec<ComponentId> {
300 self.dependencies
301 .get(id)
302 .map(|d| d.keys().cloned().collect())
303 .unwrap_or_default()
304 }
305
306 pub fn rdeps(&self, id: &ComponentId) -> Vec<ComponentId> {
309 self.reverse_deps
310 .get(id)
311 .map(|parents| parents.iter().cloned().collect())
312 .unwrap_or_default()
313 }
314
315 pub fn transitive_deps(&self, id: &ComponentId) -> BTreeSet<ComponentId> {
319 let mut visited = BTreeSet::new();
320 let mut stack = vec![id.clone()];
321 while let Some(current) = stack.pop() {
322 if let Some(children) = self.dependencies.get(¤t) {
323 for child in children.keys() {
324 if visited.insert(child.clone()) {
325 stack.push(child.clone());
326 }
327 }
328 }
329 }
330 visited
331 }
332
333 pub fn ecosystems(&self) -> BTreeSet<String> {
335 self.components
336 .values()
337 .filter_map(|c| c.ecosystem.clone())
338 .collect()
339 }
340
341 pub fn licenses(&self) -> BTreeSet<String> {
343 self.components
344 .values()
345 .flat_map(|c| c.licenses.iter().cloned())
346 .collect()
347 }
348
349 pub fn missing_hashes(&self) -> Vec<ComponentId> {
353 self.components
354 .iter()
355 .filter(|(_, c)| c.hashes.is_empty())
356 .map(|(id, _)| id.clone())
357 .collect()
358 }
359
360 pub fn by_purl(&self, purl: &str) -> Option<&Component> {
362 let id = ComponentId::new(Some(purl), &[]);
363 self.components.get(&id)
364 }
365
366 pub fn detect_cycles(&self) -> Vec<Vec<ComponentId>> {
375 enum Frame {
376 Enter(ComponentId),
377 Exit(ComponentId),
378 }
379
380 let mut visited = BTreeSet::new();
381 let mut on_stack = BTreeSet::new();
382 let mut path = Vec::new();
383 let mut cycles = Vec::new();
384
385 let mut stack: Vec<Frame> = self
386 .dependencies
387 .keys()
388 .rev()
389 .map(|k| Frame::Enter(k.clone()))
390 .collect();
391
392 while let Some(frame) = stack.pop() {
393 match frame {
394 Frame::Enter(node) => {
395 if visited.contains(&node) {
396 continue;
397 }
398 visited.insert(node.clone());
399 on_stack.insert(node.clone());
400 path.push(node.clone());
401 stack.push(Frame::Exit(node.clone()));
402
403 if let Some(children) = self.dependencies.get(&node) {
404 for child in children.keys().rev() {
405 if !visited.contains(child) {
406 stack.push(Frame::Enter(child.clone()));
407 } else if on_stack.contains(child) {
408 if let Some(start) = path.iter().position(|n| n == child) {
409 let mut cycle: Vec<_> = path[start..].to_vec();
410 cycle.push(child.clone());
411 cycles.push(cycle);
412 }
413 }
414 }
415 }
416 }
417 Frame::Exit(node) => {
418 path.pop();
419 on_stack.remove(&node);
420 }
421 }
422 }
423
424 cycles
425 }
426}
427
428impl Component {
429 pub fn normalize(&mut self) {
434 let normalized_hashes: BTreeMap<String, String> = self
435 .hashes
436 .iter()
437 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
438 .collect();
439 self.hashes = normalized_hashes;
440 }
441}
442
443pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
457 PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
458}
459
460pub fn parse_license_expression(license: &str) -> BTreeSet<String> {
480 match spdx::Expression::parse(license) {
481 Ok(expr) => {
482 let ids: BTreeSet<String> = expr
483 .requirements()
484 .map(|r| match &r.req.license {
485 spdx::LicenseItem::Spdx { id, .. } => id.name.to_string(),
486 other => other.to_string(),
487 })
488 .collect();
489 if ids.is_empty() {
490 BTreeSet::from([license.to_string()])
492 } else {
493 ids
494 }
495 }
496 Err(_) => {
497 BTreeSet::from([license.to_string()])
499 }
500 }
501}
502
503pub fn canonical_algorithm_name(name: &str) -> String {
518 match name.replace('-', "").to_uppercase().as_str() {
519 "MD2" => "MD2",
520 "MD4" => "MD4",
521 "MD5" => "MD5",
522 "MD6" => "MD6",
523 "SHA1" => "SHA-1",
524 "SHA224" => "SHA-224",
525 "SHA256" => "SHA-256",
526 "SHA384" => "SHA-384",
527 "SHA512" => "SHA-512",
528 "SHA3256" => "SHA3-256",
529 "SHA3384" => "SHA3-384",
530 "SHA3512" => "SHA3-512",
531 "BLAKE2B256" => "BLAKE2b-256",
532 "BLAKE2B384" => "BLAKE2b-384",
533 "BLAKE2B512" => "BLAKE2b-512",
534 "BLAKE3" => "BLAKE3",
535 "ADLER32" => "ADLER-32",
536 _ => return name.to_string(),
537 }
538 .to_string()
539}
540
541pub fn hash_algorithm_strength(name: &str) -> Option<u8> {
563 let canonical = canonical_algorithm_name(name);
564 match canonical.as_str() {
565 "ADLER-32" => Some(0),
566 "MD2" | "MD4" | "MD5" => Some(1),
567 "SHA-1" => Some(2),
568 "SHA-224" => Some(3),
569 "SHA-256" | "SHA3-256" | "BLAKE2b-256" | "BLAKE3" | "MD6" => Some(4),
570 "SHA-384" | "SHA3-384" | "BLAKE2b-384" => Some(5),
571 "SHA-512" | "SHA3-512" | "BLAKE2b-512" => Some(6),
572 _ => None,
573 }
574}
575
576pub fn is_hash_algorithm_downgrade(
601 old_hashes: &BTreeMap<String, String>,
602 new_hashes: &BTreeMap<String, String>,
603) -> bool {
604 if old_hashes.is_empty() || new_hashes.is_empty() {
605 return false;
606 }
607
608 let old_max = old_hashes
609 .keys()
610 .filter_map(|k| hash_algorithm_strength(k))
611 .max();
612 let new_max = new_hashes
613 .keys()
614 .filter_map(|k| hash_algorithm_strength(k))
615 .max();
616
617 match (old_max, new_max) {
618 (Some(old_strength), Some(new_strength)) => new_strength < old_strength,
619 _ => false,
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn test_component_id_purl() {
629 let purl = "pkg:npm/left-pad@1.3.0";
630 let id = ComponentId::new(Some(purl), &[]);
631 assert_eq!(id.as_str(), purl);
632 }
633
634 #[test]
635 fn test_component_id_hash_stability() {
636 let props = [("name", "foo"), ("version", "1.0")];
637 let id1 = ComponentId::new(None, &props);
638 let id2 = ComponentId::new(None, &props);
639 assert_eq!(id1, id2);
640 assert!(id1.as_str().starts_with("h:"));
641 }
642
643 #[test]
644 fn test_normalization() {
645 let mut comp = Component::new("test".to_string(), Some("1.0".to_string()));
646 comp.licenses.insert("MIT".to_string());
647 comp.licenses.insert("Apache-2.0".to_string());
648 comp.hashes.insert("SHA-256".to_string(), "ABC".to_string());
649
650 comp.normalize();
651
652 assert_eq!(
654 comp.licenses,
655 BTreeSet::from(["Apache-2.0".to_string(), "MIT".to_string()])
656 );
657 assert_eq!(comp.hashes.get("sha-256").unwrap(), "abc");
658 }
659
660 #[test]
661 fn test_parse_license_expression() {
662 let ids = parse_license_expression("MIT OR Apache-2.0");
664 assert!(ids.contains("MIT"));
665 assert!(ids.contains("Apache-2.0"));
666 assert_eq!(ids.len(), 2);
667
668 let ids = parse_license_expression("MIT");
670 assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
671
672 let ids = parse_license_expression("MIT AND Apache-2.0");
674 assert!(ids.contains("MIT"));
675 assert!(ids.contains("Apache-2.0"));
676
677 let ids = parse_license_expression("Custom License");
679 assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
680
681 let ids = parse_license_expression("LicenseRef-proprietary");
683 assert_eq!(ids, BTreeSet::from(["LicenseRef-proprietary".to_string()]));
684 }
685
686 #[test]
687 fn test_parse_license_expression_licenseref_and_spdx() {
688 let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
690 assert!(ids.contains("LicenseRef-proprietary"));
691 assert!(ids.contains("Apache-2.0"));
692 assert_eq!(ids.len(), 2);
693 }
694
695 #[test]
696 fn test_parse_license_expression_licenseref_or_spdx() {
697 let ids = parse_license_expression("LicenseRef-custom OR MIT");
699 assert!(ids.contains("LicenseRef-custom"));
700 assert!(ids.contains("MIT"));
701 assert_eq!(ids.len(), 2);
702 }
703
704 #[test]
705 fn test_parse_license_expression_multiple_licenserefs() {
706 let ids = parse_license_expression("LicenseRef-a AND LicenseRef-b");
708 assert!(ids.contains("LicenseRef-a"));
709 assert!(ids.contains("LicenseRef-b"));
710 assert_eq!(ids.len(), 2);
711 }
712
713 #[test]
714 fn test_parse_license_expression_complex_mixed() {
715 let ids = parse_license_expression("(MIT OR LicenseRef-custom) AND Apache-2.0");
717 assert!(ids.contains("MIT"));
718 assert!(ids.contains("LicenseRef-custom"));
719 assert!(ids.contains("Apache-2.0"));
720 assert_eq!(ids.len(), 3);
721 }
722
723 #[test]
724 fn test_parse_license_expression_documentref() {
725 let ids = parse_license_expression("DocumentRef-ext:LicenseRef-custom");
727 assert_eq!(
728 ids,
729 BTreeSet::from(["DocumentRef-ext:LicenseRef-custom".to_string()])
730 );
731 }
732
733 #[test]
734 fn test_license_set_equality() {
735 let mut c1 = Component::new("test".into(), None);
737 c1.licenses.insert("MIT".into());
738 c1.licenses.insert("Apache-2.0".into());
739
740 let mut c2 = Component::new("test".into(), None);
741 c2.licenses.insert("Apache-2.0".into());
742 c2.licenses.insert("MIT".into());
743
744 assert_eq!(c1.licenses, c2.licenses);
745 }
746
747 #[test]
748 fn test_query_api() {
749 let mut sbom = Sbom::default();
750 let c1 = Component::new("a".into(), Some("1".into()));
751 let c2 = Component::new("b".into(), Some("1".into()));
752 let c3 = Component::new("c".into(), Some("1".into()));
753
754 let id1 = c1.id.clone();
755 let id2 = c2.id.clone();
756 let id3 = c3.id.clone();
757
758 sbom.components.insert(id1.clone(), c1);
759 sbom.components.insert(id2.clone(), c2);
760 sbom.components.insert(id3.clone(), c3);
761
762 sbom.dependencies
764 .entry(id1.clone())
765 .or_default()
766 .insert(id2.clone(), DependencyKind::Runtime);
767 sbom.dependencies
768 .entry(id2.clone())
769 .or_default()
770 .insert(id3.clone(), DependencyKind::Runtime);
771 sbom.rebuild_reverse_deps();
772
773 assert_eq!(sbom.roots(), vec![id1.clone()]);
774 assert_eq!(sbom.deps(&id1), vec![id2.clone()]);
775 assert_eq!(sbom.rdeps(&id2), vec![id1.clone()]);
776
777 let transitive = sbom.transitive_deps(&id1);
778 assert!(transitive.contains(&id2));
779 assert!(transitive.contains(&id3));
780 assert_eq!(transitive.len(), 2);
781
782 assert_eq!(sbom.missing_hashes().len(), 3);
783 }
784
785 #[test]
786 fn test_ecosystems_query() {
787 let mut sbom = Sbom::default();
788
789 let mut c1 = Component::new("lodash".into(), Some("1.0".into()));
790 c1.ecosystem = Some("npm".into());
791 let mut c2 = Component::new("serde".into(), Some("1.0".into()));
792 c2.ecosystem = Some("cargo".into());
793 let mut c3 = Component::new("other-npm".into(), Some("1.0".into()));
794 c3.ecosystem = Some("npm".into());
795 let c4 = Component::new("no-ecosystem".into(), Some("1.0".into()));
796
797 sbom.components.insert(c1.id.clone(), c1);
798 sbom.components.insert(c2.id.clone(), c2);
799 sbom.components.insert(c3.id.clone(), c3);
800 sbom.components.insert(c4.id.clone(), c4);
801
802 let ecosystems = sbom.ecosystems();
803 assert_eq!(ecosystems.len(), 2);
804 assert!(ecosystems.contains("npm"));
805 assert!(ecosystems.contains("cargo"));
806 }
807
808 #[test]
809 fn test_licenses_query() {
810 let mut sbom = Sbom::default();
811
812 let mut c1 = Component::new("a".into(), Some("1.0".into()));
813 c1.licenses.insert("MIT".into());
814 c1.licenses.insert("Apache-2.0".into());
815 let mut c2 = Component::new("b".into(), Some("1.0".into()));
816 c2.licenses.insert("MIT".into());
817 c2.licenses.insert("GPL-3.0-only".into());
818 let c3 = Component::new("c".into(), Some("1.0".into()));
819
820 sbom.components.insert(c1.id.clone(), c1);
821 sbom.components.insert(c2.id.clone(), c2);
822 sbom.components.insert(c3.id.clone(), c3);
823
824 let licenses = sbom.licenses();
825 assert_eq!(licenses.len(), 3);
826 assert!(licenses.contains("MIT"));
827 assert!(licenses.contains("Apache-2.0"));
828 assert!(licenses.contains("GPL-3.0-only"));
829 }
830
831 #[test]
832 fn test_by_purl() {
833 let mut sbom = Sbom::default();
834
835 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
836 c1.purl = Some("pkg:npm/lodash@4.17.21".into());
837 c1.id = ComponentId::new(c1.purl.as_deref(), &[]);
838 let c2 = Component::new("no-purl".into(), Some("1.0".into()));
839
840 sbom.components.insert(c1.id.clone(), c1);
841 sbom.components.insert(c2.id.clone(), c2);
842
843 let found = sbom.by_purl("pkg:npm/lodash@4.17.21");
844 assert!(found.is_some());
845 assert_eq!(found.unwrap().name, "lodash");
846
847 assert!(sbom.by_purl("pkg:npm/nonexistent@1.0").is_none());
848 }
849
850 #[test]
851 fn test_component_id_unparseable_purl() {
852 let id = ComponentId::new(Some("not-a-valid-purl-but-still-a-string"), &[]);
854 assert_eq!(id.as_str(), "not-a-valid-purl-but-still-a-string");
855 }
856
857 #[test]
858 fn test_component_id_display() {
859 let id = ComponentId::new(Some("pkg:npm/foo@1.0"), &[]);
860 assert_eq!(format!("{}", id), "pkg:npm/foo@1.0");
861 }
862
863 #[test]
864 fn test_sbom_normalize_clears_metadata() {
865 let mut sbom = Sbom::default();
866 sbom.metadata.timestamp = Some("2024-01-01T00:00:00Z".into());
867 sbom.metadata.tools.push("syft".into());
868 sbom.metadata.authors.push("alice".into());
869
870 let c = Component::new("a".into(), Some("1".into()));
871 sbom.components.insert(c.id.clone(), c);
872
873 sbom.normalize();
874
875 assert!(sbom.metadata.timestamp.is_none());
876 assert!(sbom.metadata.tools.is_empty());
877 assert!(sbom.metadata.authors.is_empty());
878 }
879
880 #[test]
881 fn test_missing_hashes_mixed() {
882 let mut sbom = Sbom::default();
883
884 let c1 = Component::new("no-hash".into(), Some("1.0".into()));
885 let mut c2 = Component::new("has-hash".into(), Some("1.0".into()));
886 c2.hashes.insert("sha256".into(), "abc".into());
887
888 sbom.components.insert(c1.id.clone(), c1);
889 sbom.components.insert(c2.id.clone(), c2);
890
891 let missing = sbom.missing_hashes();
892 assert_eq!(missing.len(), 1);
893 }
894
895 #[test]
896 fn test_ecosystem_from_purl() {
897 use super::ecosystem_from_purl;
898
899 assert_eq!(
900 ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
901 Some("npm".to_string())
902 );
903 assert_eq!(
904 ecosystem_from_purl("pkg:cargo/serde@1.0.0"),
905 Some("cargo".to_string())
906 );
907 assert_eq!(
908 ecosystem_from_purl("pkg:pypi/requests@2.28.0"),
909 Some("pypi".to_string())
910 );
911 assert_eq!(
912 ecosystem_from_purl("pkg:maven/org.apache/commons@1.0"),
913 Some("maven".to_string())
914 );
915 assert_eq!(ecosystem_from_purl("invalid-purl"), None);
916 assert_eq!(ecosystem_from_purl(""), None);
917 }
918
919 #[test]
920 fn test_canonical_algorithm_name() {
921 assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
923 assert_eq!(canonical_algorithm_name("SHA1"), "SHA-1");
924 assert_eq!(canonical_algorithm_name("SHA384"), "SHA-384");
925 assert_eq!(canonical_algorithm_name("SHA512"), "SHA-512");
926 assert_eq!(canonical_algorithm_name("SHA224"), "SHA-224");
927
928 assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
930 assert_eq!(canonical_algorithm_name("SHA-1"), "SHA-1");
931 assert_eq!(canonical_algorithm_name("SHA-384"), "SHA-384");
932
933 assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
935 assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
936
937 assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
939 assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
940
941 assert_eq!(canonical_algorithm_name("MD5"), "MD5");
943 assert_eq!(canonical_algorithm_name("md5"), "MD5");
944
945 assert_eq!(canonical_algorithm_name("BLAKE2b-256"), "BLAKE2b-256");
947 assert_eq!(canonical_algorithm_name("BLAKE2B256"), "BLAKE2b-256");
948 assert_eq!(canonical_algorithm_name("BLAKE3"), "BLAKE3");
949
950 assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
952 assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
953
954 assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
956 }
957
958 #[test]
959 fn test_hash_algorithm_strength_ordering() {
960 let md5 = hash_algorithm_strength("MD5").unwrap();
962 let sha1 = hash_algorithm_strength("SHA-1").unwrap();
963 let sha224 = hash_algorithm_strength("SHA-224").unwrap();
964 let sha256 = hash_algorithm_strength("SHA-256").unwrap();
965 let sha384 = hash_algorithm_strength("SHA-384").unwrap();
966 let sha512 = hash_algorithm_strength("SHA-512").unwrap();
967
968 assert!(md5 < sha1);
969 assert!(sha1 < sha224);
970 assert!(sha224 < sha256);
971 assert!(sha256 < sha384);
972 assert!(sha384 < sha512);
973 }
974
975 #[test]
976 fn test_hash_algorithm_strength_variants() {
977 assert_eq!(
979 hash_algorithm_strength("sha256"),
980 hash_algorithm_strength("SHA-256")
981 );
982 assert_eq!(
983 hash_algorithm_strength("sha-1"),
984 hash_algorithm_strength("SHA1")
985 );
986
987 assert_eq!(
989 hash_algorithm_strength("SHA3-256"),
990 hash_algorithm_strength("SHA-256")
991 );
992 assert_eq!(
993 hash_algorithm_strength("SHA3-512"),
994 hash_algorithm_strength("SHA-512")
995 );
996
997 assert_eq!(
999 hash_algorithm_strength("BLAKE2b-256"),
1000 hash_algorithm_strength("SHA-256")
1001 );
1002 assert_eq!(
1003 hash_algorithm_strength("BLAKE3"),
1004 hash_algorithm_strength("SHA-256")
1005 );
1006
1007 assert_eq!(hash_algorithm_strength("TIGER"), None);
1009 assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
1010 }
1011
1012 #[test]
1013 fn test_hash_algorithm_strength_adler() {
1014 let adler = hash_algorithm_strength("ADLER-32").unwrap();
1015 let md5 = hash_algorithm_strength("MD5").unwrap();
1016 assert!(adler < md5);
1017 }
1018
1019 #[test]
1020 fn test_is_hash_algorithm_downgrade_sha256_to_md5() {
1021 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1022 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1023 assert!(is_hash_algorithm_downgrade(&old, &new));
1024 }
1025
1026 #[test]
1027 fn test_is_hash_algorithm_downgrade_upgrade_not_flagged() {
1028 let old: BTreeMap<String, String> = [("sha-1".into(), "abc".into())].into();
1029 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1030 assert!(!is_hash_algorithm_downgrade(&old, &new));
1031 }
1032
1033 #[test]
1034 fn test_is_hash_algorithm_downgrade_same_algorithm() {
1035 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1036 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1037 assert!(!is_hash_algorithm_downgrade(&old, &new));
1038 }
1039
1040 #[test]
1041 fn test_is_hash_algorithm_downgrade_empty_old() {
1042 let old: BTreeMap<String, String> = BTreeMap::new();
1043 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1044 assert!(!is_hash_algorithm_downgrade(&old, &new));
1045 }
1046
1047 #[test]
1048 fn test_is_hash_algorithm_downgrade_empty_new() {
1049 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1050 let new: BTreeMap<String, String> = BTreeMap::new();
1051 assert!(!is_hash_algorithm_downgrade(&old, &new));
1052 }
1053
1054 #[test]
1055 fn test_is_hash_algorithm_downgrade_multi_algorithm() {
1056 let old: BTreeMap<String, String> = [
1058 ("sha-256".into(), "abc".into()),
1059 ("md5".into(), "xyz".into()),
1060 ]
1061 .into();
1062 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1063 assert!(is_hash_algorithm_downgrade(&old, &new));
1064 }
1065
1066 #[test]
1067 fn test_is_hash_algorithm_downgrade_multi_algorithm_kept() {
1068 let old: BTreeMap<String, String> = [
1070 ("sha-256".into(), "abc".into()),
1071 ("md5".into(), "xyz".into()),
1072 ]
1073 .into();
1074 let new: BTreeMap<String, String> = [
1075 ("sha-256".into(), "def".into()),
1076 ("sha-1".into(), "ghi".into()),
1077 ]
1078 .into();
1079 assert!(!is_hash_algorithm_downgrade(&old, &new));
1080 }
1081
1082 #[test]
1083 fn test_detect_cycles_none() {
1084 let mut sbom = Sbom::default();
1085 let c1 = Component::new("a".into(), Some("1".into()));
1086 let c2 = Component::new("b".into(), Some("1".into()));
1087 let c3 = Component::new("c".into(), Some("1".into()));
1088
1089 let id1 = c1.id.clone();
1090 let id2 = c2.id.clone();
1091 let id3 = c3.id.clone();
1092
1093 sbom.components.insert(id1.clone(), c1);
1094 sbom.components.insert(id2.clone(), c2);
1095 sbom.components.insert(id3.clone(), c3);
1096
1097 sbom.dependencies
1099 .entry(id1.clone())
1100 .or_default()
1101 .insert(id2.clone(), DependencyKind::Runtime);
1102 sbom.dependencies
1103 .entry(id2.clone())
1104 .or_default()
1105 .insert(id3.clone(), DependencyKind::Runtime);
1106
1107 assert!(sbom.detect_cycles().is_empty());
1108 }
1109
1110 #[test]
1111 fn test_detect_cycles_simple() {
1112 let mut sbom = Sbom::default();
1113 let c1 = Component::new("a".into(), Some("1".into()));
1114 let c2 = Component::new("b".into(), Some("1".into()));
1115
1116 let id1 = c1.id.clone();
1117 let id2 = c2.id.clone();
1118
1119 sbom.components.insert(id1.clone(), c1);
1120 sbom.components.insert(id2.clone(), c2);
1121
1122 sbom.dependencies
1124 .entry(id1.clone())
1125 .or_default()
1126 .insert(id2.clone(), DependencyKind::Runtime);
1127 sbom.dependencies
1128 .entry(id2.clone())
1129 .or_default()
1130 .insert(id1.clone(), DependencyKind::Runtime);
1131
1132 let cycles = sbom.detect_cycles();
1133 assert_eq!(cycles.len(), 1);
1134 assert_eq!(cycles[0].first(), cycles[0].last());
1136 }
1137
1138 #[test]
1139 fn test_detect_cycles_self_loop() {
1140 let mut sbom = Sbom::default();
1141 let c1 = Component::new("a".into(), Some("1".into()));
1142 let id1 = c1.id.clone();
1143 sbom.components.insert(id1.clone(), c1);
1144
1145 sbom.dependencies
1147 .entry(id1.clone())
1148 .or_default()
1149 .insert(id1.clone(), DependencyKind::Runtime);
1150
1151 let cycles = sbom.detect_cycles();
1152 assert_eq!(cycles.len(), 1);
1153 assert_eq!(cycles[0].len(), 2); }
1155
1156 #[test]
1157 fn test_detect_cycles_empty_graph() {
1158 let sbom = Sbom::default();
1159 assert!(sbom.detect_cycles().is_empty());
1160 }
1161
1162 #[test]
1163 fn test_detect_cycles_three_node() {
1164 let mut sbom = Sbom::default();
1165 let c1 = Component::new("a".into(), Some("1".into()));
1166 let c2 = Component::new("b".into(), Some("1".into()));
1167 let c3 = Component::new("c".into(), Some("1".into()));
1168
1169 let id1 = c1.id.clone();
1170 let id2 = c2.id.clone();
1171 let id3 = c3.id.clone();
1172
1173 sbom.components.insert(id1.clone(), c1);
1174 sbom.components.insert(id2.clone(), c2);
1175 sbom.components.insert(id3.clone(), c3);
1176
1177 sbom.dependencies
1179 .entry(id1.clone())
1180 .or_default()
1181 .insert(id2.clone(), DependencyKind::Runtime);
1182 sbom.dependencies
1183 .entry(id2.clone())
1184 .or_default()
1185 .insert(id3.clone(), DependencyKind::Runtime);
1186 sbom.dependencies
1187 .entry(id3.clone())
1188 .or_default()
1189 .insert(id1.clone(), DependencyKind::Runtime);
1190
1191 let cycles = sbom.detect_cycles();
1192 assert_eq!(cycles.len(), 1);
1193 assert_eq!(cycles[0].first(), cycles[0].last());
1194 assert_eq!(cycles[0].len(), 4); }
1196
1197 #[test]
1198 fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
1199 let old: BTreeMap<String, String> = [("TIGER".into(), "abc".into())].into();
1201 let new: BTreeMap<String, String> = [("WHIRLPOOL".into(), "def".into())].into();
1202 assert!(!is_hash_algorithm_downgrade(&old, &new));
1203 }
1204}