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 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub license_expression: Option<String>,
216 pub hashes: BTreeMap<String, String>,
218 pub source_ids: Vec<String>,
220}
221
222impl Component {
223 pub fn new(name: String, version: Option<String>) -> Self {
228 let mut props = vec![("name", name.as_str())];
229 if let Some(v) = &version {
230 props.push(("version", v));
231 }
232 let id = ComponentId::new(None, &props);
233
234 Self {
235 id,
236 name,
237 version,
238 ecosystem: None,
239 supplier: None,
240 description: None,
241 purl: None,
242 licenses: BTreeSet::new(),
243 license_expression: None,
244 hashes: BTreeMap::new(),
245 source_ids: Vec::new(),
246 }
247 }
248
249 pub fn licensing(&self) -> Licensing<'_> {
251 Licensing {
252 expression: self.license_expression.as_deref(),
253 ids: &self.licenses,
254 }
255 }
256}
257
258impl Sbom {
259 pub fn normalize(&mut self) {
269 self.components.sort_keys();
271
272 for component in self.components.values_mut() {
274 component.normalize();
275 }
276
277 self.metadata.timestamp = None;
279 self.metadata.tools.clear();
280 self.metadata.authors.clear();
281
282 self.rebuild_reverse_deps();
283 }
284
285 pub fn rebuild_reverse_deps(&mut self) {
291 self.reverse_deps.clear();
292 for (parent, children) in &self.dependencies {
293 for child in children.keys() {
294 self.reverse_deps
295 .entry(child.clone())
296 .or_default()
297 .insert(parent.clone());
298 }
299 }
300 }
301
302 pub fn roots(&self) -> Vec<ComponentId> {
307 self.components
308 .keys()
309 .filter(|id| self.reverse_deps.get(*id).is_none_or(BTreeSet::is_empty))
310 .cloned()
311 .collect()
312 }
313
314 pub fn deps(&self, id: &ComponentId) -> Vec<ComponentId> {
316 self.dependencies
317 .get(id)
318 .map(|d| d.keys().cloned().collect())
319 .unwrap_or_default()
320 }
321
322 pub fn rdeps(&self, id: &ComponentId) -> Vec<ComponentId> {
325 self.reverse_deps
326 .get(id)
327 .map(|parents| parents.iter().cloned().collect())
328 .unwrap_or_default()
329 }
330
331 pub fn transitive_deps(&self, id: &ComponentId) -> BTreeSet<ComponentId> {
335 let mut visited = BTreeSet::new();
336 let mut stack = vec![id.clone()];
337 while let Some(current) = stack.pop() {
338 if let Some(children) = self.dependencies.get(¤t) {
339 for child in children.keys() {
340 if visited.insert(child.clone()) {
341 stack.push(child.clone());
342 }
343 }
344 }
345 }
346 visited
347 }
348
349 pub fn ecosystems(&self) -> BTreeSet<String> {
351 self.components
352 .values()
353 .filter_map(|c| c.ecosystem.clone())
354 .collect()
355 }
356
357 pub fn licenses(&self) -> BTreeSet<String> {
359 self.components
360 .values()
361 .flat_map(|c| c.licenses.iter().cloned())
362 .collect()
363 }
364
365 pub fn missing_hashes(&self) -> Vec<ComponentId> {
369 self.components
370 .iter()
371 .filter(|(_, c)| c.hashes.is_empty())
372 .map(|(id, _)| id.clone())
373 .collect()
374 }
375
376 pub fn by_purl(&self, purl: &str) -> Option<&Component> {
378 let id = ComponentId::new(Some(purl), &[]);
379 self.components.get(&id)
380 }
381
382 pub fn detect_cycles(&self) -> Vec<Vec<ComponentId>> {
391 enum Frame {
392 Enter(ComponentId),
393 Exit(ComponentId),
394 }
395
396 let mut visited = BTreeSet::new();
397 let mut on_stack = BTreeSet::new();
398 let mut path = Vec::new();
399 let mut cycles = Vec::new();
400
401 let mut stack: Vec<Frame> = self
402 .dependencies
403 .keys()
404 .rev()
405 .map(|k| Frame::Enter(k.clone()))
406 .collect();
407
408 while let Some(frame) = stack.pop() {
409 match frame {
410 Frame::Enter(node) => {
411 if visited.contains(&node) {
412 continue;
413 }
414 visited.insert(node.clone());
415 on_stack.insert(node.clone());
416 path.push(node.clone());
417 stack.push(Frame::Exit(node.clone()));
418
419 if let Some(children) = self.dependencies.get(&node) {
420 for child in children.keys().rev() {
421 if !visited.contains(child) {
422 stack.push(Frame::Enter(child.clone()));
423 } else if on_stack.contains(child) {
424 if let Some(start) = path.iter().position(|n| n == child) {
425 let mut cycle: Vec<_> = path[start..].to_vec();
426 cycle.push(child.clone());
427 cycles.push(cycle);
428 }
429 }
430 }
431 }
432 }
433 Frame::Exit(node) => {
434 path.pop();
435 on_stack.remove(&node);
436 }
437 }
438 }
439
440 cycles
441 }
442}
443
444impl Component {
445 pub fn normalize(&mut self) {
450 let normalized_hashes: BTreeMap<String, String> = self
451 .hashes
452 .iter()
453 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
454 .collect();
455 self.hashes = normalized_hashes;
456 }
457}
458
459pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
473 PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
474}
475
476pub fn parse_license_expression(license: &str) -> BTreeSet<String> {
496 match spdx::Expression::parse(license) {
497 Ok(expr) => {
498 let ids: BTreeSet<String> = expr
499 .requirements()
500 .map(|r| match &r.req.license {
501 spdx::LicenseItem::Spdx { id, .. } => id.name.to_string(),
502 other => other.to_string(),
503 })
504 .collect();
505 if ids.is_empty() {
506 BTreeSet::from([license.to_string()])
508 } else {
509 ids
510 }
511 }
512 Err(_) => {
513 BTreeSet::from([license.to_string()])
515 }
516 }
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
522pub struct LicenseRequirement {
523 pub license: String,
525 pub or_later: bool,
527 pub exception: Option<String>,
529}
530
531impl LicenseRequirement {
532 pub fn new(license: impl Into<String>) -> Self {
534 Self {
535 license: license.into(),
536 or_later: false,
537 exception: None,
538 }
539 }
540}
541
542impl std::fmt::Display for LicenseRequirement {
543 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544 write!(f, "{}", self.license)?;
545 if self.or_later {
546 f.write_str("+")?;
547 }
548 if let Some(exception) = &self.exception {
549 write!(f, " WITH {exception}")?;
550 }
551 Ok(())
552 }
553}
554
555fn to_requirement(req: &spdx::LicenseReq) -> LicenseRequirement {
556 let (license, or_later) = match &req.license {
557 spdx::LicenseItem::Spdx { id, or_later } => (id.name.to_string(), *or_later),
558 other => (other.to_string(), false),
559 };
560 LicenseRequirement {
561 license,
562 or_later,
563 exception: req.addition.as_ref().map(|a| a.to_string()),
564 }
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573pub struct Licensing<'a> {
574 pub expression: Option<&'a str>,
576 pub ids: &'a BTreeSet<String>,
578}
579
580impl<'a> Licensing<'a> {
581 pub fn from_ids(ids: &'a BTreeSet<String>) -> Self {
583 Self {
584 expression: None,
585 ids,
586 }
587 }
588
589 pub fn satisfiable<F>(&self, mut acceptable: F) -> bool
610 where
611 F: FnMut(&LicenseRequirement) -> bool,
612 {
613 if let Some(expression) = self.expression {
614 if let Ok(expr) = spdx::Expression::parse(expression) {
615 return expr.evaluate(|req| acceptable(&to_requirement(req)));
616 }
617 }
618 self.ids
619 .iter()
620 .all(|id| acceptable(&LicenseRequirement::new(id)))
621 }
622
623 pub fn requirements(&self) -> BTreeSet<LicenseRequirement> {
626 if let Some(expression) = self.expression {
627 if let Ok(expr) = spdx::Expression::parse(expression) {
628 let reqs: BTreeSet<LicenseRequirement> = expr
629 .requirements()
630 .map(|r| to_requirement(&r.req))
631 .collect();
632 if !reqs.is_empty() {
633 return reqs;
634 }
635 }
636 }
637 self.ids.iter().map(LicenseRequirement::new).collect()
638 }
639
640 fn choices(&self) -> Option<Choices> {
643 if let Some(expression) = self.expression {
644 if let Ok(expr) = spdx::Expression::parse(expression) {
645 return expression_choices(&expr);
646 }
647 }
648 Some(BTreeSet::from([self
649 .ids
650 .iter()
651 .map(LicenseRequirement::new)
652 .collect()]))
653 }
654
655 fn mandatory_copyleft(&self) -> BTreeSet<String> {
657 self.requirements()
658 .into_iter()
659 .filter(|r| is_copyleft_license(&r.license))
660 .filter(|r| !self.satisfiable(|other| other.license != r.license))
661 .map(|r| r.license)
662 .collect()
663 }
664
665 fn copyleft_burdens(&self) -> Option<Burdens> {
668 let burdens = self
669 .choices()?
670 .into_iter()
671 .map(|choice| {
672 choice
673 .into_iter()
674 .filter(|r| is_copyleft_license(&r.license))
675 .map(|r| r.license)
676 .collect()
677 })
678 .collect();
679 Some(minimal_sets(burdens))
680 }
681}
682
683type Choices = BTreeSet<BTreeSet<LicenseRequirement>>;
685
686type Burdens = BTreeSet<BTreeSet<String>>;
688
689const MAX_CHOICES: usize = 64;
692
693fn minimal_sets<T: Ord + Clone>(sets: BTreeSet<BTreeSet<T>>) -> BTreeSet<BTreeSet<T>> {
695 sets.iter()
696 .filter(|set| {
697 sets.iter()
698 .all(|other| other == *set || !other.is_subset(set))
699 })
700 .cloned()
701 .collect()
702}
703
704fn expression_choices(expr: &spdx::Expression) -> Option<Choices> {
706 let mut stack: Vec<Choices> = Vec::new();
707
708 for node in expr.iter() {
709 match node {
710 spdx::expression::ExprNode::Req(req) => {
711 stack.push(BTreeSet::from([BTreeSet::from([to_requirement(&req.req)])]));
712 }
713 spdx::expression::ExprNode::Op(op) => {
714 let rhs = stack.pop()?;
715 let lhs = stack.pop()?;
716 let combined: Choices = match op {
717 spdx::expression::Operator::Or => lhs.union(&rhs).cloned().collect(),
718 spdx::expression::Operator::And => lhs
719 .iter()
720 .flat_map(|l| rhs.iter().map(|r| l.union(r).cloned().collect()))
721 .collect(),
722 };
723 if combined.len() > MAX_CHOICES {
724 return None;
725 }
726 stack.push(minimal_sets(combined));
727 }
728 }
729 }
730
731 let choices = stack.pop()?;
732 stack.is_empty().then_some(choices)
733}
734
735pub fn licensings_equivalent(a: Licensing<'_>, b: Licensing<'_>) -> bool {
758 match (a.choices(), b.choices()) {
759 (Some(x), Some(y)) => x == y,
760 _ => match (a.expression, b.expression) {
761 (Some(x), Some(y)) => license_expressions_equivalent(x, y),
762 _ => false,
763 },
764 }
765}
766
767pub fn copyleft_obligations_added(old: Licensing<'_>, new: Licensing<'_>) -> BTreeSet<String> {
797 match (old.copyleft_burdens(), new.copyleft_burdens()) {
798 (Some(offered), Some(demanded)) => {
799 if demanded
800 .iter()
801 .any(|burden| offered.iter().any(|had| burden.is_subset(had)))
802 {
803 return BTreeSet::new();
804 }
805 let unavoidable = offered
806 .into_iter()
807 .reduce(|acc, had| acc.intersection(&had).cloned().collect())
808 .unwrap_or_default();
809 demanded
810 .into_iter()
811 .flatten()
812 .filter(|license| !unavoidable.contains(license))
813 .collect()
814 }
815 _ => {
816 let already = old.mandatory_copyleft();
817 if new.satisfiable(|r| !is_copyleft_license(&r.license) || already.contains(&r.license))
818 {
819 return BTreeSet::new();
820 }
821 new.requirements()
822 .into_iter()
823 .filter(|r| is_copyleft_license(&r.license) && !already.contains(&r.license))
824 .map(|r| r.license)
825 .collect()
826 }
827 }
828}
829
830pub fn license_expressions_equivalent(a: &str, b: &str) -> bool {
850 match (spdx::Expression::parse(a), spdx::Expression::parse(b)) {
851 (Ok(x), Ok(y)) => match (expression_choices(&x), expression_choices(&y)) {
852 (Some(cx), Some(cy)) => cx == cy,
853 _ => x == y,
854 },
855 _ => a == b,
856 }
857}
858
859pub fn canonical_algorithm_name(name: &str) -> String {
874 match name.replace('-', "").to_uppercase().as_str() {
875 "MD2" => "MD2",
876 "MD4" => "MD4",
877 "MD5" => "MD5",
878 "MD6" => "MD6",
879 "SHA1" => "SHA-1",
880 "SHA224" => "SHA-224",
881 "SHA256" => "SHA-256",
882 "SHA384" => "SHA-384",
883 "SHA512" => "SHA-512",
884 "SHA3256" => "SHA3-256",
885 "SHA3384" => "SHA3-384",
886 "SHA3512" => "SHA3-512",
887 "BLAKE2B256" => "BLAKE2b-256",
888 "BLAKE2B384" => "BLAKE2b-384",
889 "BLAKE2B512" => "BLAKE2b-512",
890 "BLAKE3" => "BLAKE3",
891 "ADLER32" => "ADLER-32",
892 _ => return name.to_string(),
893 }
894 .to_string()
895}
896
897pub fn hash_algorithm_strength(name: &str) -> Option<u8> {
919 let canonical = canonical_algorithm_name(name);
920 match canonical.as_str() {
921 "ADLER-32" => Some(0),
922 "MD2" | "MD4" | "MD5" => Some(1),
923 "SHA-1" => Some(2),
924 "SHA-224" => Some(3),
925 "SHA-256" | "SHA3-256" | "BLAKE2b-256" | "BLAKE3" | "MD6" => Some(4),
926 "SHA-384" | "SHA3-384" | "BLAKE2b-384" => Some(5),
927 "SHA-512" | "SHA3-512" | "BLAKE2b-512" => Some(6),
928 _ => None,
929 }
930}
931
932pub fn is_hash_algorithm_downgrade(
957 old_hashes: &BTreeMap<String, String>,
958 new_hashes: &BTreeMap<String, String>,
959) -> bool {
960 if old_hashes.is_empty() || new_hashes.is_empty() {
961 return false;
962 }
963
964 let old_max = old_hashes
965 .keys()
966 .filter_map(|k| hash_algorithm_strength(k))
967 .max();
968 let new_max = new_hashes
969 .keys()
970 .filter_map(|k| hash_algorithm_strength(k))
971 .max();
972
973 match (old_max, new_max) {
974 (Some(old_strength), Some(new_strength)) => new_strength < old_strength,
975 _ => false,
976 }
977}
978
979pub fn is_copyleft_license(id: &str) -> bool {
999 spdx::license_id(id)
1000 .map(|l| l.is_copyleft())
1001 .unwrap_or(false)
1002}
1003
1004pub fn copyleft_introduced(old: &BTreeSet<String>, new: &BTreeSet<String>) -> bool {
1025 new.iter()
1026 .any(|id| is_copyleft_license(id) && !old.contains(id))
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032
1033 #[test]
1034 fn test_component_id_purl() {
1035 let purl = "pkg:npm/left-pad@1.3.0";
1036 let id = ComponentId::new(Some(purl), &[]);
1037 assert_eq!(id.as_str(), purl);
1038 }
1039
1040 #[test]
1041 fn test_component_id_hash_stability() {
1042 let props = [("name", "foo"), ("version", "1.0")];
1043 let id1 = ComponentId::new(None, &props);
1044 let id2 = ComponentId::new(None, &props);
1045 assert_eq!(id1, id2);
1046 assert!(id1.as_str().starts_with("h:"));
1047 }
1048
1049 #[test]
1050 fn test_normalization() {
1051 let mut comp = Component::new("test".to_string(), Some("1.0".to_string()));
1052 comp.licenses.insert("MIT".to_string());
1053 comp.licenses.insert("Apache-2.0".to_string());
1054 comp.hashes.insert("SHA-256".to_string(), "ABC".to_string());
1055
1056 comp.normalize();
1057
1058 assert_eq!(
1059 comp.licenses,
1060 BTreeSet::from(["Apache-2.0".to_string(), "MIT".to_string()])
1061 );
1062 assert_eq!(comp.hashes.get("sha-256").unwrap(), "abc");
1063 }
1064
1065 fn licensing<'a>(expression: &'a str, ids: &'a BTreeSet<String>) -> Licensing<'a> {
1066 Licensing {
1067 expression: Some(expression),
1068 ids,
1069 }
1070 }
1071
1072 #[test]
1073 fn test_licensing_satisfiable_operators() {
1074 let ids: BTreeSet<String> = ["Apache-2.0".into(), "MIT".into()].into();
1075 let only_mit = |r: &LicenseRequirement| r.license == "MIT";
1076
1077 assert!(licensing("MIT OR Apache-2.0", &ids).satisfiable(only_mit));
1078 assert!(!licensing("MIT AND Apache-2.0", &ids).satisfiable(only_mit));
1079
1080 let nested: BTreeSet<String> =
1081 ["Apache-2.0".into(), "BSD-3-Clause".into(), "MIT".into()].into();
1082 let allowed: BTreeSet<String> = ["BSD-3-Clause".into(), "MIT".into()].into();
1083 assert!(licensing("(MIT OR Apache-2.0) AND BSD-3-Clause", &nested)
1084 .satisfiable(|r| allowed.contains(&r.license)));
1085 assert!(!licensing("(MIT AND Apache-2.0) AND BSD-3-Clause", &nested)
1086 .satisfiable(|r| allowed.contains(&r.license)));
1087 }
1088
1089 #[test]
1090 fn test_licensing_without_expression_is_a_conjunction() {
1091 let ids: BTreeSet<String> = ["Apache-2.0".into(), "MIT".into()].into();
1092 assert!(!Licensing::from_ids(&ids).satisfiable(|r| r.license == "MIT"));
1093 assert!(Licensing::from_ids(&ids).satisfiable(|_| true));
1094 }
1095
1096 #[test]
1097 fn test_licensing_falls_back_on_free_text() {
1098 let ids: BTreeSet<String> = ["Custom License".into()].into();
1099 let free_text = licensing("Custom License", &ids);
1100 assert!(free_text.satisfiable(|r| r.license == "Custom License"));
1101 assert!(!free_text.satisfiable(|_| false));
1102 }
1103
1104 #[test]
1105 fn test_licensing_requirements_keep_exceptions() {
1106 let ids: BTreeSet<String> = ["GPL-2.0-only".into()].into();
1107 let reqs = licensing("GPL-2.0-only WITH Classpath-exception-2.0", &ids).requirements();
1108
1109 assert_eq!(reqs.len(), 1);
1110 let req = reqs.iter().next().unwrap();
1111 assert_eq!(req.license, "GPL-2.0-only");
1112 assert_eq!(req.exception.as_deref(), Some("Classpath-exception-2.0"));
1113 assert_eq!(req.to_string(), "GPL-2.0-only WITH Classpath-exception-2.0");
1114 }
1115
1116 #[test]
1117 fn test_licensing_requirements_keep_or_later() {
1118 let ids: BTreeSet<String> = ["Apache-2.0".into()].into();
1119 let req = licensing("Apache-2.0+", &ids)
1120 .requirements()
1121 .into_iter()
1122 .next()
1123 .unwrap();
1124
1125 assert_eq!(req.license, "Apache-2.0");
1126 assert!(req.or_later);
1127 assert_eq!(req.to_string(), "Apache-2.0+");
1128 }
1129
1130 #[test]
1131 fn test_copyleft_obligations_added_respects_choice() {
1132 let mit: BTreeSet<String> = ["MIT".into()].into();
1133 let both: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
1134 let old = licensing("MIT", &mit);
1135
1136 assert!(
1137 copyleft_obligations_added(old, licensing("MIT OR GPL-3.0-only", &both)).is_empty()
1138 );
1139 assert_eq!(
1140 copyleft_obligations_added(old, licensing("MIT AND GPL-3.0-only", &both)),
1141 BTreeSet::from(["GPL-3.0-only".to_string()])
1142 );
1143 }
1144
1145 #[test]
1146 fn test_copyleft_obligations_added_carried_over() {
1147 let gpl: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1148 let both: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
1149
1150 assert!(copyleft_obligations_added(
1151 licensing("GPL-3.0-only", &gpl),
1152 licensing("GPL-3.0-only AND MIT", &both)
1153 )
1154 .is_empty());
1155
1156 let agpl: BTreeSet<String> = ["AGPL-3.0-only".into()].into();
1158 assert_eq!(
1159 copyleft_obligations_added(
1160 licensing("GPL-3.0-only", &gpl),
1161 licensing("AGPL-3.0-only", &agpl)
1162 ),
1163 BTreeSet::from(["AGPL-3.0-only".to_string()])
1164 );
1165 }
1166
1167 #[test]
1168 fn test_copyleft_obligations_added_losing_the_permissive_choice() {
1169 let both: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
1170 let gpl: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1171
1172 assert_eq!(
1173 copyleft_obligations_added(
1174 licensing("MIT OR GPL-3.0-only", &both),
1175 licensing("GPL-3.0-only", &gpl)
1176 ),
1177 BTreeSet::from(["GPL-3.0-only".to_string()])
1178 );
1179 }
1180
1181 fn copyleft_added(old: &str, new: &str) -> BTreeSet<String> {
1182 let old_ids = parse_license_expression(old);
1183 let new_ids = parse_license_expression(new);
1184 copyleft_obligations_added(licensing(old, &old_ids), licensing(new, &new_ids))
1185 }
1186
1187 fn licenses(names: &[&str]) -> BTreeSet<String> {
1188 names.iter().map(|n| n.to_string()).collect()
1189 }
1190
1191 #[test]
1192 fn test_copyleft_obligations_added_ignores_a_choice_between_copyleft_licenses() {
1193 for (old, new) in [
1194 (
1195 "GPL-2.0-only OR GPL-3.0-only",
1196 "GPL-2.0-only OR GPL-3.0-only OR LGPL-3.0-only",
1197 ),
1198 (
1199 "MIT AND (GPL-2.0-only OR GPL-3.0-only)",
1200 "Apache-2.0 AND (GPL-2.0-only OR GPL-3.0-only)",
1201 ),
1202 (
1203 "MPL-2.0 OR GPL-2.0-only OR LGPL-2.1-only",
1204 "MPL-2.0 OR GPL-2.0-only OR LGPL-2.1-only",
1205 ),
1206 ] {
1207 assert!(
1208 copyleft_added(old, new).is_empty(),
1209 "{old} -> {new} forces no copyleft the consumer could not already have taken"
1210 );
1211 }
1212 }
1213
1214 #[test]
1215 fn test_copyleft_obligations_added_fires_on_every_tightening() {
1216 for (old, new, introduced) in [
1217 (
1218 "GPL-2.0-only OR GPL-3.0-only",
1219 "GPL-2.0-only AND GPL-3.0-only",
1220 &["GPL-2.0-only", "GPL-3.0-only"][..],
1221 ),
1222 ("MIT OR GPL-3.0-only", "GPL-3.0-only", &["GPL-3.0-only"]),
1223 ("GPL-3.0-only", "AGPL-3.0-only", &["AGPL-3.0-only"]),
1224 ("MIT", "MIT AND GPL-3.0-only", &["GPL-3.0-only"]),
1225 (
1226 "GPL-2.0-only OR GPL-3.0-only",
1227 "AGPL-3.0-only",
1228 &["AGPL-3.0-only"],
1229 ),
1230 ] {
1231 assert_eq!(
1232 copyleft_added(old, new),
1233 licenses(introduced),
1234 "{old} -> {new}"
1235 );
1236 }
1237 }
1238
1239 #[test]
1240 fn test_copyleft_obligations_added_spans_the_minimal_choices_less_what_old_forced() {
1241 for (old, new, introduced) in [
1242 (
1243 "MIT",
1244 "GPL-3.0-only AND (MPL-2.0 OR ISC)",
1245 &["GPL-3.0-only"][..],
1246 ),
1247 (
1248 "GPL-2.0-only",
1249 "GPL-2.0-only AND GPL-3.0-only",
1250 &["GPL-3.0-only"],
1251 ),
1252 (
1253 "MIT",
1254 "GPL-2.0-only OR AGPL-3.0-only",
1255 &["AGPL-3.0-only", "GPL-2.0-only"],
1256 ),
1257 (
1258 "MIT",
1259 "(GPL-3.0-only AND MPL-2.0) OR (AGPL-3.0-only AND EPL-2.0)",
1260 &["AGPL-3.0-only", "GPL-3.0-only", "MPL-2.0"],
1261 ),
1262 ] {
1263 assert_eq!(
1264 copyleft_added(old, new),
1265 licenses(introduced),
1266 "{old} -> {new}"
1267 );
1268 }
1269 }
1270
1271 #[test]
1272 fn test_copyleft_obligations_added_falls_back_past_max_choices() {
1273 let wide = "(MIT OR Apache-2.0) AND (BSD-2-Clause OR BSD-3-Clause) \
1274 AND (ISC OR Zlib) AND (MPL-2.0 OR EPL-2.0) \
1275 AND (GPL-2.0-only OR GPL-3.0-only) AND (LGPL-2.1-only OR LGPL-3.0-only) \
1276 AND (CC0-1.0 OR Unlicense)";
1277
1278 assert_eq!(
1279 copyleft_added("MIT", wide),
1280 licenses(&[
1281 "GPL-2.0-only",
1282 "GPL-3.0-only",
1283 "LGPL-2.1-only",
1284 "LGPL-3.0-only",
1285 "MPL-2.0",
1286 ])
1287 );
1288 }
1289
1290 #[test]
1291 fn test_copyleft_obligations_added_matches_flat_sets() {
1292 let old: BTreeSet<String> = ["MIT".into()].into();
1293 let new: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
1294
1295 assert_eq!(
1296 copyleft_obligations_added(Licensing::from_ids(&old), Licensing::from_ids(&new)),
1297 BTreeSet::from(["GPL-3.0-only".to_string()])
1298 );
1299 assert!(copyleft_introduced(&old, &new));
1300
1301 assert!(
1302 copyleft_obligations_added(Licensing::from_ids(&new), Licensing::from_ids(&new))
1303 .is_empty()
1304 );
1305 assert!(!copyleft_introduced(&new, &new));
1306 }
1307
1308 #[test]
1309 fn test_license_expressions_equivalent() {
1310 assert!(license_expressions_equivalent(
1311 "MIT OR Apache-2.0",
1312 "( MIT OR (Apache-2.0) )"
1313 ));
1314 assert!(!license_expressions_equivalent(
1315 "MIT OR Apache-2.0",
1316 "MIT AND Apache-2.0"
1317 ));
1318 assert!(!license_expressions_equivalent(
1319 "GPL-2.0-only",
1320 "GPL-2.0-only WITH Classpath-exception-2.0"
1321 ));
1322 assert!(license_expressions_equivalent("Custom Text", "Custom Text"));
1323 assert!(!license_expressions_equivalent("Custom Text", "Other Text"));
1324 }
1325
1326 #[test]
1327 fn test_license_expressions_equivalent_falls_back_past_max_choices() {
1328 let wide = "(MIT OR Apache-2.0) AND (BSD-2-Clause OR BSD-3-Clause) \
1329 AND (ISC OR Zlib) AND (MPL-2.0 OR EPL-2.0) \
1330 AND (GPL-2.0-only OR GPL-3.0-only) AND (LGPL-2.1-only OR LGPL-3.0-only) \
1331 AND (CC0-1.0 OR Unlicense)";
1332
1333 assert!(license_expressions_equivalent(wide, wide));
1334 assert!(license_expressions_equivalent(
1335 wide,
1336 &wide.replace("(MIT OR Apache-2.0)", "((MIT OR Apache-2.0))")
1337 ));
1338 assert!(!license_expressions_equivalent(
1339 wide,
1340 &wide.replace("MIT OR Apache-2.0", "Apache-2.0 OR MIT")
1341 ));
1342
1343 let ids: BTreeSet<String> = wide
1344 .split_whitespace()
1345 .map(|word| word.trim_matches(['(', ')']).to_string())
1346 .filter(|word| word != "AND" && word != "OR")
1347 .collect();
1348 assert!(!licensings_equivalent(
1349 Licensing {
1350 expression: Some(wide),
1351 ids: &ids,
1352 },
1353 Licensing::from_ids(&ids)
1354 ));
1355 }
1356
1357 #[test]
1358 fn test_license_expressions_equivalent_ignores_operand_order() {
1359 assert!(license_expressions_equivalent(
1360 "MIT OR Apache-2.0",
1361 "Apache-2.0 OR MIT"
1362 ));
1363 assert!(license_expressions_equivalent(
1364 "(MIT OR Apache-2.0) AND BSD-3-Clause",
1365 "(BSD-3-Clause AND Apache-2.0) OR (BSD-3-Clause AND MIT)"
1366 ));
1367 assert!(license_expressions_equivalent(
1368 "MIT",
1369 "MIT OR (MIT AND Apache-2.0)"
1370 ));
1371 assert!(!license_expressions_equivalent(
1372 "MIT OR Apache-2.0",
1373 "MIT OR BSD-3-Clause"
1374 ));
1375 }
1376
1377 #[test]
1378 fn test_licensings_equivalent_reads_a_bare_set_as_a_conjunction() {
1379 let ids: BTreeSet<String> = ["GPL-3.0-only".to_string(), "MIT".to_string()].into();
1380 let flat = Licensing::from_ids(&ids);
1381
1382 assert!(licensings_equivalent(
1383 Licensing {
1384 expression: Some("MIT AND GPL-3.0-only"),
1385 ids: &ids,
1386 },
1387 flat
1388 ));
1389 assert!(!licensings_equivalent(
1390 Licensing {
1391 expression: Some("MIT OR GPL-3.0-only"),
1392 ids: &ids,
1393 },
1394 flat
1395 ));
1396 assert!(licensings_equivalent(flat, flat));
1397 }
1398
1399 #[test]
1400 fn test_licensings_equivalent_keeps_decorated_requirements() {
1401 let gpl: BTreeSet<String> = ["GPL-2.0-only".to_string()].into();
1402 assert!(!licensings_equivalent(
1403 Licensing {
1404 expression: Some("GPL-2.0-only WITH Classpath-exception-2.0"),
1405 ids: &gpl,
1406 },
1407 Licensing::from_ids(&gpl)
1408 ));
1409 let apache: BTreeSet<String> = ["Apache-2.0".to_string()].into();
1410 assert!(!licensings_equivalent(
1411 Licensing {
1412 expression: Some("Apache-2.0+"),
1413 ids: &apache,
1414 },
1415 Licensing::from_ids(&apache)
1416 ));
1417 }
1418
1419 #[test]
1420 fn test_licensings_equivalent_falls_back_to_the_identifier_set() {
1421 let ids: BTreeSet<String> = ["Custom Text".to_string()].into();
1422 assert!(licensings_equivalent(
1423 Licensing {
1424 expression: Some("Custom Text"),
1425 ids: &ids,
1426 },
1427 Licensing::from_ids(&ids)
1428 ));
1429 }
1430
1431 #[test]
1432 fn test_component_licensing_defaults_to_ids() {
1433 let mut comp = Component::new("demo".into(), None);
1434 comp.licenses.insert("MIT".into());
1435
1436 assert_eq!(comp.licensing().expression, None);
1437 assert_eq!(
1438 comp.licensing().requirements(),
1439 BTreeSet::from([LicenseRequirement::new("MIT")])
1440 );
1441 }
1442
1443 #[test]
1444 fn test_parse_license_expression() {
1445 let ids = parse_license_expression("MIT OR Apache-2.0");
1447 assert!(ids.contains("MIT"));
1448 assert!(ids.contains("Apache-2.0"));
1449 assert_eq!(ids.len(), 2);
1450
1451 let ids = parse_license_expression("MIT");
1453 assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
1454
1455 let ids = parse_license_expression("MIT AND Apache-2.0");
1457 assert!(ids.contains("MIT"));
1458 assert!(ids.contains("Apache-2.0"));
1459
1460 let ids = parse_license_expression("Custom License");
1462 assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
1463
1464 let ids = parse_license_expression("LicenseRef-proprietary");
1466 assert_eq!(ids, BTreeSet::from(["LicenseRef-proprietary".to_string()]));
1467 }
1468
1469 #[test]
1470 fn test_parse_license_expression_licenseref_and_spdx() {
1471 let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
1473 assert!(ids.contains("LicenseRef-proprietary"));
1474 assert!(ids.contains("Apache-2.0"));
1475 assert_eq!(ids.len(), 2);
1476 }
1477
1478 #[test]
1479 fn test_parse_license_expression_licenseref_or_spdx() {
1480 let ids = parse_license_expression("LicenseRef-custom OR MIT");
1482 assert!(ids.contains("LicenseRef-custom"));
1483 assert!(ids.contains("MIT"));
1484 assert_eq!(ids.len(), 2);
1485 }
1486
1487 #[test]
1488 fn test_parse_license_expression_multiple_licenserefs() {
1489 let ids = parse_license_expression("LicenseRef-a AND LicenseRef-b");
1491 assert!(ids.contains("LicenseRef-a"));
1492 assert!(ids.contains("LicenseRef-b"));
1493 assert_eq!(ids.len(), 2);
1494 }
1495
1496 #[test]
1497 fn test_parse_license_expression_complex_mixed() {
1498 let ids = parse_license_expression("(MIT OR LicenseRef-custom) AND Apache-2.0");
1500 assert!(ids.contains("MIT"));
1501 assert!(ids.contains("LicenseRef-custom"));
1502 assert!(ids.contains("Apache-2.0"));
1503 assert_eq!(ids.len(), 3);
1504 }
1505
1506 #[test]
1507 fn test_parse_license_expression_documentref() {
1508 let ids = parse_license_expression("DocumentRef-ext:LicenseRef-custom");
1510 assert_eq!(
1511 ids,
1512 BTreeSet::from(["DocumentRef-ext:LicenseRef-custom".to_string()])
1513 );
1514 }
1515
1516 #[test]
1517 fn test_license_set_equality() {
1518 let mut c1 = Component::new("test".into(), None);
1520 c1.licenses.insert("MIT".into());
1521 c1.licenses.insert("Apache-2.0".into());
1522
1523 let mut c2 = Component::new("test".into(), None);
1524 c2.licenses.insert("Apache-2.0".into());
1525 c2.licenses.insert("MIT".into());
1526
1527 assert_eq!(c1.licenses, c2.licenses);
1528 }
1529
1530 #[test]
1531 fn test_query_api() {
1532 let mut sbom = Sbom::default();
1533 let c1 = Component::new("a".into(), Some("1".into()));
1534 let c2 = Component::new("b".into(), Some("1".into()));
1535 let c3 = Component::new("c".into(), Some("1".into()));
1536
1537 let id1 = c1.id.clone();
1538 let id2 = c2.id.clone();
1539 let id3 = c3.id.clone();
1540
1541 sbom.components.insert(id1.clone(), c1);
1542 sbom.components.insert(id2.clone(), c2);
1543 sbom.components.insert(id3.clone(), c3);
1544
1545 sbom.dependencies
1547 .entry(id1.clone())
1548 .or_default()
1549 .insert(id2.clone(), DependencyKind::Runtime);
1550 sbom.dependencies
1551 .entry(id2.clone())
1552 .or_default()
1553 .insert(id3.clone(), DependencyKind::Runtime);
1554 sbom.rebuild_reverse_deps();
1555
1556 assert_eq!(sbom.roots(), vec![id1.clone()]);
1557 assert_eq!(sbom.deps(&id1), vec![id2.clone()]);
1558 assert_eq!(sbom.rdeps(&id2), vec![id1.clone()]);
1559
1560 let transitive = sbom.transitive_deps(&id1);
1561 assert!(transitive.contains(&id2));
1562 assert!(transitive.contains(&id3));
1563 assert_eq!(transitive.len(), 2);
1564
1565 assert_eq!(sbom.missing_hashes().len(), 3);
1566 }
1567
1568 #[test]
1569 fn test_ecosystems_query() {
1570 let mut sbom = Sbom::default();
1571
1572 let mut c1 = Component::new("lodash".into(), Some("1.0".into()));
1573 c1.ecosystem = Some("npm".into());
1574 let mut c2 = Component::new("serde".into(), Some("1.0".into()));
1575 c2.ecosystem = Some("cargo".into());
1576 let mut c3 = Component::new("other-npm".into(), Some("1.0".into()));
1577 c3.ecosystem = Some("npm".into());
1578 let c4 = Component::new("no-ecosystem".into(), Some("1.0".into()));
1579
1580 sbom.components.insert(c1.id.clone(), c1);
1581 sbom.components.insert(c2.id.clone(), c2);
1582 sbom.components.insert(c3.id.clone(), c3);
1583 sbom.components.insert(c4.id.clone(), c4);
1584
1585 let ecosystems = sbom.ecosystems();
1586 assert_eq!(ecosystems.len(), 2);
1587 assert!(ecosystems.contains("npm"));
1588 assert!(ecosystems.contains("cargo"));
1589 }
1590
1591 #[test]
1592 fn test_licenses_query() {
1593 let mut sbom = Sbom::default();
1594
1595 let mut c1 = Component::new("a".into(), Some("1.0".into()));
1596 c1.licenses.insert("MIT".into());
1597 c1.licenses.insert("Apache-2.0".into());
1598 let mut c2 = Component::new("b".into(), Some("1.0".into()));
1599 c2.licenses.insert("MIT".into());
1600 c2.licenses.insert("GPL-3.0-only".into());
1601 let c3 = Component::new("c".into(), Some("1.0".into()));
1602
1603 sbom.components.insert(c1.id.clone(), c1);
1604 sbom.components.insert(c2.id.clone(), c2);
1605 sbom.components.insert(c3.id.clone(), c3);
1606
1607 let licenses = sbom.licenses();
1608 assert_eq!(licenses.len(), 3);
1609 assert!(licenses.contains("MIT"));
1610 assert!(licenses.contains("Apache-2.0"));
1611 assert!(licenses.contains("GPL-3.0-only"));
1612 }
1613
1614 #[test]
1615 fn test_by_purl() {
1616 let mut sbom = Sbom::default();
1617
1618 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1619 c1.purl = Some("pkg:npm/lodash@4.17.21".into());
1620 c1.id = ComponentId::new(c1.purl.as_deref(), &[]);
1621 let c2 = Component::new("no-purl".into(), Some("1.0".into()));
1622
1623 sbom.components.insert(c1.id.clone(), c1);
1624 sbom.components.insert(c2.id.clone(), c2);
1625
1626 let found = sbom.by_purl("pkg:npm/lodash@4.17.21");
1627 assert!(found.is_some());
1628 assert_eq!(found.unwrap().name, "lodash");
1629
1630 assert!(sbom.by_purl("pkg:npm/nonexistent@1.0").is_none());
1631 }
1632
1633 #[test]
1634 fn test_component_id_unparseable_purl() {
1635 let id = ComponentId::new(Some("not-a-valid-purl-but-still-a-string"), &[]);
1637 assert_eq!(id.as_str(), "not-a-valid-purl-but-still-a-string");
1638 }
1639
1640 #[test]
1641 fn test_component_id_display() {
1642 let id = ComponentId::new(Some("pkg:npm/foo@1.0"), &[]);
1643 assert_eq!(format!("{}", id), "pkg:npm/foo@1.0");
1644 }
1645
1646 #[test]
1647 fn test_sbom_normalize_clears_metadata() {
1648 let mut sbom = Sbom::default();
1649 sbom.metadata.timestamp = Some("2024-01-01T00:00:00Z".into());
1650 sbom.metadata.tools.push("syft".into());
1651 sbom.metadata.authors.push("alice".into());
1652
1653 let c = Component::new("a".into(), Some("1".into()));
1654 sbom.components.insert(c.id.clone(), c);
1655
1656 sbom.normalize();
1657
1658 assert!(sbom.metadata.timestamp.is_none());
1659 assert!(sbom.metadata.tools.is_empty());
1660 assert!(sbom.metadata.authors.is_empty());
1661 }
1662
1663 #[test]
1664 fn test_missing_hashes_mixed() {
1665 let mut sbom = Sbom::default();
1666
1667 let c1 = Component::new("no-hash".into(), Some("1.0".into()));
1668 let mut c2 = Component::new("has-hash".into(), Some("1.0".into()));
1669 c2.hashes.insert("sha256".into(), "abc".into());
1670
1671 sbom.components.insert(c1.id.clone(), c1);
1672 sbom.components.insert(c2.id.clone(), c2);
1673
1674 let missing = sbom.missing_hashes();
1675 assert_eq!(missing.len(), 1);
1676 }
1677
1678 #[test]
1679 fn test_ecosystem_from_purl() {
1680 use super::ecosystem_from_purl;
1681
1682 assert_eq!(
1683 ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
1684 Some("npm".to_string())
1685 );
1686 assert_eq!(
1687 ecosystem_from_purl("pkg:cargo/serde@1.0.0"),
1688 Some("cargo".to_string())
1689 );
1690 assert_eq!(
1691 ecosystem_from_purl("pkg:pypi/requests@2.28.0"),
1692 Some("pypi".to_string())
1693 );
1694 assert_eq!(
1695 ecosystem_from_purl("pkg:maven/org.apache/commons@1.0"),
1696 Some("maven".to_string())
1697 );
1698 assert_eq!(ecosystem_from_purl("invalid-purl"), None);
1699 assert_eq!(ecosystem_from_purl(""), None);
1700 }
1701
1702 #[test]
1703 fn test_canonical_algorithm_name() {
1704 assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
1706 assert_eq!(canonical_algorithm_name("SHA1"), "SHA-1");
1707 assert_eq!(canonical_algorithm_name("SHA384"), "SHA-384");
1708 assert_eq!(canonical_algorithm_name("SHA512"), "SHA-512");
1709 assert_eq!(canonical_algorithm_name("SHA224"), "SHA-224");
1710
1711 assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
1713 assert_eq!(canonical_algorithm_name("SHA-1"), "SHA-1");
1714 assert_eq!(canonical_algorithm_name("SHA-384"), "SHA-384");
1715
1716 assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
1718 assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
1719
1720 assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
1722 assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
1723
1724 assert_eq!(canonical_algorithm_name("MD5"), "MD5");
1726 assert_eq!(canonical_algorithm_name("md5"), "MD5");
1727
1728 assert_eq!(canonical_algorithm_name("BLAKE2b-256"), "BLAKE2b-256");
1730 assert_eq!(canonical_algorithm_name("BLAKE2B256"), "BLAKE2b-256");
1731 assert_eq!(canonical_algorithm_name("BLAKE3"), "BLAKE3");
1732
1733 assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
1735 assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
1736
1737 assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
1739 }
1740
1741 #[test]
1742 fn test_hash_algorithm_strength_ordering() {
1743 let md5 = hash_algorithm_strength("MD5").unwrap();
1745 let sha1 = hash_algorithm_strength("SHA-1").unwrap();
1746 let sha224 = hash_algorithm_strength("SHA-224").unwrap();
1747 let sha256 = hash_algorithm_strength("SHA-256").unwrap();
1748 let sha384 = hash_algorithm_strength("SHA-384").unwrap();
1749 let sha512 = hash_algorithm_strength("SHA-512").unwrap();
1750
1751 assert!(md5 < sha1);
1752 assert!(sha1 < sha224);
1753 assert!(sha224 < sha256);
1754 assert!(sha256 < sha384);
1755 assert!(sha384 < sha512);
1756 }
1757
1758 #[test]
1759 fn test_hash_algorithm_strength_variants() {
1760 assert_eq!(
1762 hash_algorithm_strength("sha256"),
1763 hash_algorithm_strength("SHA-256")
1764 );
1765 assert_eq!(
1766 hash_algorithm_strength("sha-1"),
1767 hash_algorithm_strength("SHA1")
1768 );
1769
1770 assert_eq!(
1772 hash_algorithm_strength("SHA3-256"),
1773 hash_algorithm_strength("SHA-256")
1774 );
1775 assert_eq!(
1776 hash_algorithm_strength("SHA3-512"),
1777 hash_algorithm_strength("SHA-512")
1778 );
1779
1780 assert_eq!(
1782 hash_algorithm_strength("BLAKE2b-256"),
1783 hash_algorithm_strength("SHA-256")
1784 );
1785 assert_eq!(
1786 hash_algorithm_strength("BLAKE3"),
1787 hash_algorithm_strength("SHA-256")
1788 );
1789
1790 assert_eq!(hash_algorithm_strength("TIGER"), None);
1792 assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
1793 }
1794
1795 #[test]
1796 fn test_hash_algorithm_strength_adler() {
1797 let adler = hash_algorithm_strength("ADLER-32").unwrap();
1798 let md5 = hash_algorithm_strength("MD5").unwrap();
1799 assert!(adler < md5);
1800 }
1801
1802 #[test]
1803 fn test_is_hash_algorithm_downgrade_sha256_to_md5() {
1804 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1805 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1806 assert!(is_hash_algorithm_downgrade(&old, &new));
1807 }
1808
1809 #[test]
1810 fn test_is_hash_algorithm_downgrade_upgrade_not_flagged() {
1811 let old: BTreeMap<String, String> = [("sha-1".into(), "abc".into())].into();
1812 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1813 assert!(!is_hash_algorithm_downgrade(&old, &new));
1814 }
1815
1816 #[test]
1817 fn test_is_hash_algorithm_downgrade_same_algorithm() {
1818 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1819 let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1820 assert!(!is_hash_algorithm_downgrade(&old, &new));
1821 }
1822
1823 #[test]
1824 fn test_is_hash_algorithm_downgrade_empty_old() {
1825 let old: BTreeMap<String, String> = BTreeMap::new();
1826 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1827 assert!(!is_hash_algorithm_downgrade(&old, &new));
1828 }
1829
1830 #[test]
1831 fn test_is_hash_algorithm_downgrade_empty_new() {
1832 let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1833 let new: BTreeMap<String, String> = BTreeMap::new();
1834 assert!(!is_hash_algorithm_downgrade(&old, &new));
1835 }
1836
1837 #[test]
1838 fn test_is_hash_algorithm_downgrade_multi_algorithm() {
1839 let old: BTreeMap<String, String> = [
1841 ("sha-256".into(), "abc".into()),
1842 ("md5".into(), "xyz".into()),
1843 ]
1844 .into();
1845 let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1846 assert!(is_hash_algorithm_downgrade(&old, &new));
1847 }
1848
1849 #[test]
1850 fn test_is_hash_algorithm_downgrade_multi_algorithm_kept() {
1851 let old: BTreeMap<String, String> = [
1853 ("sha-256".into(), "abc".into()),
1854 ("md5".into(), "xyz".into()),
1855 ]
1856 .into();
1857 let new: BTreeMap<String, String> = [
1858 ("sha-256".into(), "def".into()),
1859 ("sha-1".into(), "ghi".into()),
1860 ]
1861 .into();
1862 assert!(!is_hash_algorithm_downgrade(&old, &new));
1863 }
1864
1865 #[test]
1866 fn test_detect_cycles_none() {
1867 let mut sbom = Sbom::default();
1868 let c1 = Component::new("a".into(), Some("1".into()));
1869 let c2 = Component::new("b".into(), Some("1".into()));
1870 let c3 = Component::new("c".into(), Some("1".into()));
1871
1872 let id1 = c1.id.clone();
1873 let id2 = c2.id.clone();
1874 let id3 = c3.id.clone();
1875
1876 sbom.components.insert(id1.clone(), c1);
1877 sbom.components.insert(id2.clone(), c2);
1878 sbom.components.insert(id3.clone(), c3);
1879
1880 sbom.dependencies
1882 .entry(id1.clone())
1883 .or_default()
1884 .insert(id2.clone(), DependencyKind::Runtime);
1885 sbom.dependencies
1886 .entry(id2.clone())
1887 .or_default()
1888 .insert(id3.clone(), DependencyKind::Runtime);
1889
1890 assert!(sbom.detect_cycles().is_empty());
1891 }
1892
1893 #[test]
1894 fn test_detect_cycles_simple() {
1895 let mut sbom = Sbom::default();
1896 let c1 = Component::new("a".into(), Some("1".into()));
1897 let c2 = Component::new("b".into(), Some("1".into()));
1898
1899 let id1 = c1.id.clone();
1900 let id2 = c2.id.clone();
1901
1902 sbom.components.insert(id1.clone(), c1);
1903 sbom.components.insert(id2.clone(), c2);
1904
1905 sbom.dependencies
1907 .entry(id1.clone())
1908 .or_default()
1909 .insert(id2.clone(), DependencyKind::Runtime);
1910 sbom.dependencies
1911 .entry(id2.clone())
1912 .or_default()
1913 .insert(id1.clone(), DependencyKind::Runtime);
1914
1915 let cycles = sbom.detect_cycles();
1916 assert_eq!(cycles.len(), 1);
1917 assert_eq!(cycles[0].first(), cycles[0].last());
1919 }
1920
1921 #[test]
1922 fn test_detect_cycles_self_loop() {
1923 let mut sbom = Sbom::default();
1924 let c1 = Component::new("a".into(), Some("1".into()));
1925 let id1 = c1.id.clone();
1926 sbom.components.insert(id1.clone(), c1);
1927
1928 sbom.dependencies
1930 .entry(id1.clone())
1931 .or_default()
1932 .insert(id1.clone(), DependencyKind::Runtime);
1933
1934 let cycles = sbom.detect_cycles();
1935 assert_eq!(cycles.len(), 1);
1936 assert_eq!(cycles[0].len(), 2); }
1938
1939 #[test]
1940 fn test_detect_cycles_empty_graph() {
1941 let sbom = Sbom::default();
1942 assert!(sbom.detect_cycles().is_empty());
1943 }
1944
1945 #[test]
1946 fn test_detect_cycles_three_node() {
1947 let mut sbom = Sbom::default();
1948 let c1 = Component::new("a".into(), Some("1".into()));
1949 let c2 = Component::new("b".into(), Some("1".into()));
1950 let c3 = Component::new("c".into(), Some("1".into()));
1951
1952 let id1 = c1.id.clone();
1953 let id2 = c2.id.clone();
1954 let id3 = c3.id.clone();
1955
1956 sbom.components.insert(id1.clone(), c1);
1957 sbom.components.insert(id2.clone(), c2);
1958 sbom.components.insert(id3.clone(), c3);
1959
1960 sbom.dependencies
1962 .entry(id1.clone())
1963 .or_default()
1964 .insert(id2.clone(), DependencyKind::Runtime);
1965 sbom.dependencies
1966 .entry(id2.clone())
1967 .or_default()
1968 .insert(id3.clone(), DependencyKind::Runtime);
1969 sbom.dependencies
1970 .entry(id3.clone())
1971 .or_default()
1972 .insert(id1.clone(), DependencyKind::Runtime);
1973
1974 let cycles = sbom.detect_cycles();
1975 assert_eq!(cycles.len(), 1);
1976 assert_eq!(cycles[0].first(), cycles[0].last());
1977 assert_eq!(cycles[0].len(), 4); }
1979
1980 #[test]
1981 fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
1982 let old: BTreeMap<String, String> = [("TIGER".into(), "abc".into())].into();
1984 let new: BTreeMap<String, String> = [("WHIRLPOOL".into(), "def".into())].into();
1985 assert!(!is_hash_algorithm_downgrade(&old, &new));
1986 }
1987
1988 #[test]
1989 fn test_is_copyleft_license() {
1990 assert!(is_copyleft_license("GPL-3.0-only"));
1992 assert!(is_copyleft_license("AGPL-3.0-only"));
1993 assert!(is_copyleft_license("LGPL-3.0-only"));
1994 assert!(!is_copyleft_license("MIT"));
1996 assert!(!is_copyleft_license("Apache-2.0"));
1997 assert!(!is_copyleft_license("BSD-3-Clause"));
1998 assert!(!is_copyleft_license("LicenseRef-proprietary"));
2000 assert!(!is_copyleft_license("NOT-A-LICENSE"));
2001 }
2002
2003 #[test]
2004 fn test_copyleft_introduced_permissive_to_copyleft() {
2005 let old: BTreeSet<String> = ["MIT".into()].into();
2006 let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
2007 assert!(copyleft_introduced(&old, &new));
2008 }
2009
2010 #[test]
2011 fn test_copyleft_introduced_permissive_to_permissive() {
2012 let old: BTreeSet<String> = ["MIT".into()].into();
2013 let new: BTreeSet<String> = ["Apache-2.0".into()].into();
2014 assert!(!copyleft_introduced(&old, &new));
2015 }
2016
2017 #[test]
2018 fn test_copyleft_introduced_carried_over_not_flagged() {
2019 let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
2021 let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
2022 assert!(!copyleft_introduced(&old, &new));
2023 }
2024
2025 #[test]
2026 fn test_copyleft_introduced_added_alongside_existing() {
2027 let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
2029 let new: BTreeSet<String> = ["GPL-3.0-only".into(), "AGPL-3.0-only".into()].into();
2030 assert!(copyleft_introduced(&old, &new));
2031 }
2032}