#![doc = include_str!("../readme.md")]
pub mod versions;
use indexmap::IndexMap;
use packageurl::PackageUrl;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sbom {
pub metadata: Metadata,
pub components: IndexMap<ComponentId, Component>,
pub dependencies: BTreeMap<ComponentId, BTreeMap<ComponentId, DependencyKind>>,
#[serde(skip)]
pub reverse_deps: BTreeMap<ComponentId, BTreeSet<ComponentId>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
impl PartialEq for Sbom {
fn eq(&self, other: &Self) -> bool {
self.metadata == other.metadata
&& self.components == other.components
&& self.dependencies == other.dependencies
&& self.warnings == other.warnings
}
}
impl Eq for Sbom {}
impl Default for Sbom {
fn default() -> Self {
Self {
metadata: Metadata::default(),
components: IndexMap::new(),
dependencies: BTreeMap::new(),
reverse_deps: BTreeMap::new(),
warnings: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Metadata {
pub timestamp: Option<String>,
pub tools: Vec<String>,
pub authors: Vec<String>,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum DependencyKind {
#[default]
Runtime,
Dev,
Build,
Test,
Optional,
Provided,
}
impl fmt::Display for DependencyKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Runtime => write!(f, "runtime"),
Self::Dev => write!(f, "dev"),
Self::Build => write!(f, "build"),
Self::Test => write!(f, "test"),
Self::Optional => write!(f, "optional"),
Self::Provided => write!(f, "provided"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ComponentId(String);
impl ComponentId {
pub fn new(purl: Option<&str>, properties: &[(&str, &str)]) -> Self {
if let Some(purl) = purl {
if let Ok(parsed) = PackageUrl::from_str(purl) {
return ComponentId(parsed.to_string());
}
return ComponentId(purl.to_string());
}
let mut hasher = Sha256::new();
for (k, v) in properties {
hasher.update(k.as_bytes());
hasher.update(b":");
hasher.update(v.as_bytes());
hasher.update(b"|");
}
let hash = hex::encode(hasher.finalize());
ComponentId(format!("h:{}", hash))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ComponentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Component {
pub id: ComponentId,
pub name: String,
pub version: Option<String>,
pub ecosystem: Option<String>,
pub supplier: Option<String>,
pub description: Option<String>,
pub purl: Option<String>,
pub licenses: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license_expression: Option<String>,
pub hashes: BTreeMap<String, String>,
pub source_ids: Vec<String>,
}
impl Component {
pub fn new(name: String, version: Option<String>) -> Self {
let mut props = vec![("name", name.as_str())];
if let Some(v) = &version {
props.push(("version", v));
}
let id = ComponentId::new(None, &props);
Self {
id,
name,
version,
ecosystem: None,
supplier: None,
description: None,
purl: None,
licenses: BTreeSet::new(),
license_expression: None,
hashes: BTreeMap::new(),
source_ids: Vec::new(),
}
}
pub fn licensing(&self) -> Licensing<'_> {
Licensing {
expression: self.license_expression.as_deref(),
ids: &self.licenses,
}
}
}
impl Sbom {
pub fn normalize(&mut self) {
self.components.sort_keys();
for component in self.components.values_mut() {
component.normalize();
}
self.metadata.timestamp = None;
self.metadata.tools.clear();
self.metadata.authors.clear();
self.rebuild_reverse_deps();
}
pub fn rebuild_reverse_deps(&mut self) {
self.reverse_deps.clear();
for (parent, children) in &self.dependencies {
for child in children.keys() {
self.reverse_deps
.entry(child.clone())
.or_default()
.insert(parent.clone());
}
}
}
pub fn roots(&self) -> Vec<ComponentId> {
self.components
.keys()
.filter(|id| self.reverse_deps.get(*id).is_none_or(BTreeSet::is_empty))
.cloned()
.collect()
}
pub fn deps(&self, id: &ComponentId) -> Vec<ComponentId> {
self.dependencies
.get(id)
.map(|d| d.keys().cloned().collect())
.unwrap_or_default()
}
pub fn rdeps(&self, id: &ComponentId) -> Vec<ComponentId> {
self.reverse_deps
.get(id)
.map(|parents| parents.iter().cloned().collect())
.unwrap_or_default()
}
pub fn transitive_deps(&self, id: &ComponentId) -> BTreeSet<ComponentId> {
let mut visited = BTreeSet::new();
let mut stack = vec![id.clone()];
while let Some(current) = stack.pop() {
if let Some(children) = self.dependencies.get(¤t) {
for child in children.keys() {
if visited.insert(child.clone()) {
stack.push(child.clone());
}
}
}
}
visited
}
pub fn ecosystems(&self) -> BTreeSet<String> {
self.components
.values()
.filter_map(|c| c.ecosystem.clone())
.collect()
}
pub fn licenses(&self) -> BTreeSet<String> {
self.components
.values()
.flat_map(|c| c.licenses.iter().cloned())
.collect()
}
pub fn missing_hashes(&self) -> Vec<ComponentId> {
self.components
.iter()
.filter(|(_, c)| c.hashes.is_empty())
.map(|(id, _)| id.clone())
.collect()
}
pub fn by_purl(&self, purl: &str) -> Option<&Component> {
let id = ComponentId::new(Some(purl), &[]);
self.components.get(&id)
}
pub fn detect_cycles(&self) -> Vec<Vec<ComponentId>> {
enum Frame {
Enter(ComponentId),
Exit(ComponentId),
}
let mut visited = BTreeSet::new();
let mut on_stack = BTreeSet::new();
let mut path = Vec::new();
let mut cycles = Vec::new();
let mut stack: Vec<Frame> = self
.dependencies
.keys()
.rev()
.map(|k| Frame::Enter(k.clone()))
.collect();
while let Some(frame) = stack.pop() {
match frame {
Frame::Enter(node) => {
if visited.contains(&node) {
continue;
}
visited.insert(node.clone());
on_stack.insert(node.clone());
path.push(node.clone());
stack.push(Frame::Exit(node.clone()));
if let Some(children) = self.dependencies.get(&node) {
for child in children.keys().rev() {
if !visited.contains(child) {
stack.push(Frame::Enter(child.clone()));
} else if on_stack.contains(child) {
if let Some(start) = path.iter().position(|n| n == child) {
let mut cycle: Vec<_> = path[start..].to_vec();
cycle.push(child.clone());
cycles.push(cycle);
}
}
}
}
}
Frame::Exit(node) => {
path.pop();
on_stack.remove(&node);
}
}
}
cycles
}
}
impl Component {
pub fn normalize(&mut self) {
let normalized_hashes: BTreeMap<String, String> = self
.hashes
.iter()
.map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
.collect();
self.hashes = normalized_hashes;
}
}
pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
}
pub fn parse_license_expression(license: &str) -> BTreeSet<String> {
match spdx::Expression::parse(license) {
Ok(expr) => {
let ids: BTreeSet<String> = expr
.requirements()
.map(|r| match &r.req.license {
spdx::LicenseItem::Spdx { id, .. } => id.name.to_string(),
other => other.to_string(),
})
.collect();
if ids.is_empty() {
BTreeSet::from([license.to_string()])
} else {
ids
}
}
Err(_) => {
BTreeSet::from([license.to_string()])
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LicenseRequirement {
pub license: String,
pub or_later: bool,
pub exception: Option<String>,
}
impl LicenseRequirement {
pub fn new(license: impl Into<String>) -> Self {
Self {
license: license.into(),
or_later: false,
exception: None,
}
}
}
impl std::fmt::Display for LicenseRequirement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.license)?;
if self.or_later {
f.write_str("+")?;
}
if let Some(exception) = &self.exception {
write!(f, " WITH {exception}")?;
}
Ok(())
}
}
fn to_requirement(req: &spdx::LicenseReq) -> LicenseRequirement {
let (license, or_later) = match &req.license {
spdx::LicenseItem::Spdx { id, or_later } => (id.name.to_string(), *or_later),
other => (other.to_string(), false),
};
LicenseRequirement {
license,
or_later,
exception: req.addition.as_ref().map(|a| a.to_string()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Licensing<'a> {
pub expression: Option<&'a str>,
pub ids: &'a BTreeSet<String>,
}
impl<'a> Licensing<'a> {
pub fn from_ids(ids: &'a BTreeSet<String>) -> Self {
Self {
expression: None,
ids,
}
}
pub fn satisfiable<F>(&self, mut acceptable: F) -> bool
where
F: FnMut(&LicenseRequirement) -> bool,
{
if let Some(expression) = self.expression {
if let Ok(expr) = spdx::Expression::parse(expression) {
return expr.evaluate(|req| acceptable(&to_requirement(req)));
}
}
self.ids
.iter()
.all(|id| acceptable(&LicenseRequirement::new(id)))
}
pub fn requirements(&self) -> BTreeSet<LicenseRequirement> {
if let Some(expression) = self.expression {
if let Ok(expr) = spdx::Expression::parse(expression) {
let reqs: BTreeSet<LicenseRequirement> = expr
.requirements()
.map(|r| to_requirement(&r.req))
.collect();
if !reqs.is_empty() {
return reqs;
}
}
}
self.ids.iter().map(LicenseRequirement::new).collect()
}
fn choices(&self) -> Option<Choices> {
if let Some(expression) = self.expression {
if let Ok(expr) = spdx::Expression::parse(expression) {
return expression_choices(&expr);
}
}
Some(BTreeSet::from([self
.ids
.iter()
.map(LicenseRequirement::new)
.collect()]))
}
fn mandatory_copyleft(&self) -> BTreeSet<String> {
self.requirements()
.into_iter()
.filter(|r| is_copyleft_license(&r.license))
.filter(|r| !self.satisfiable(|other| other.license != r.license))
.map(|r| r.license)
.collect()
}
fn copyleft_burdens(&self) -> Option<Burdens> {
let burdens = self
.choices()?
.into_iter()
.map(|choice| {
choice
.into_iter()
.filter(|r| is_copyleft_license(&r.license))
.map(|r| r.license)
.collect()
})
.collect();
Some(minimal_sets(burdens))
}
}
type Choices = BTreeSet<BTreeSet<LicenseRequirement>>;
type Burdens = BTreeSet<BTreeSet<String>>;
const MAX_CHOICES: usize = 64;
fn minimal_sets<T: Ord + Clone>(sets: BTreeSet<BTreeSet<T>>) -> BTreeSet<BTreeSet<T>> {
sets.iter()
.filter(|set| {
sets.iter()
.all(|other| other == *set || !other.is_subset(set))
})
.cloned()
.collect()
}
fn expression_choices(expr: &spdx::Expression) -> Option<Choices> {
let mut stack: Vec<Choices> = Vec::new();
for node in expr.iter() {
match node {
spdx::expression::ExprNode::Req(req) => {
stack.push(BTreeSet::from([BTreeSet::from([to_requirement(&req.req)])]));
}
spdx::expression::ExprNode::Op(op) => {
let rhs = stack.pop()?;
let lhs = stack.pop()?;
let combined: Choices = match op {
spdx::expression::Operator::Or => lhs.union(&rhs).cloned().collect(),
spdx::expression::Operator::And => lhs
.iter()
.flat_map(|l| rhs.iter().map(|r| l.union(r).cloned().collect()))
.collect(),
};
if combined.len() > MAX_CHOICES {
return None;
}
stack.push(minimal_sets(combined));
}
}
}
let choices = stack.pop()?;
stack.is_empty().then_some(choices)
}
pub fn licensings_equivalent(a: Licensing<'_>, b: Licensing<'_>) -> bool {
match (a.choices(), b.choices()) {
(Some(x), Some(y)) => x == y,
_ => match (a.expression, b.expression) {
(Some(x), Some(y)) => license_expressions_equivalent(x, y),
_ => false,
},
}
}
pub fn copyleft_obligations_added(old: Licensing<'_>, new: Licensing<'_>) -> BTreeSet<String> {
match (old.copyleft_burdens(), new.copyleft_burdens()) {
(Some(offered), Some(demanded)) => {
if demanded
.iter()
.any(|burden| offered.iter().any(|had| burden.is_subset(had)))
{
return BTreeSet::new();
}
let unavoidable = offered
.into_iter()
.reduce(|acc, had| acc.intersection(&had).cloned().collect())
.unwrap_or_default();
demanded
.into_iter()
.flatten()
.filter(|license| !unavoidable.contains(license))
.collect()
}
_ => {
let already = old.mandatory_copyleft();
if new.satisfiable(|r| !is_copyleft_license(&r.license) || already.contains(&r.license))
{
return BTreeSet::new();
}
new.requirements()
.into_iter()
.filter(|r| is_copyleft_license(&r.license) && !already.contains(&r.license))
.map(|r| r.license)
.collect()
}
}
}
pub fn license_expressions_equivalent(a: &str, b: &str) -> bool {
match (spdx::Expression::parse(a), spdx::Expression::parse(b)) {
(Ok(x), Ok(y)) => match (expression_choices(&x), expression_choices(&y)) {
(Some(cx), Some(cy)) => cx == cy,
_ => x == y,
},
_ => a == b,
}
}
pub fn canonical_algorithm_name(name: &str) -> String {
match name.replace('-', "").to_uppercase().as_str() {
"MD2" => "MD2",
"MD4" => "MD4",
"MD5" => "MD5",
"MD6" => "MD6",
"SHA1" => "SHA-1",
"SHA224" => "SHA-224",
"SHA256" => "SHA-256",
"SHA384" => "SHA-384",
"SHA512" => "SHA-512",
"SHA3256" => "SHA3-256",
"SHA3384" => "SHA3-384",
"SHA3512" => "SHA3-512",
"BLAKE2B256" => "BLAKE2b-256",
"BLAKE2B384" => "BLAKE2b-384",
"BLAKE2B512" => "BLAKE2b-512",
"BLAKE3" => "BLAKE3",
"ADLER32" => "ADLER-32",
_ => return name.to_string(),
}
.to_string()
}
pub fn hash_algorithm_strength(name: &str) -> Option<u8> {
let canonical = canonical_algorithm_name(name);
match canonical.as_str() {
"ADLER-32" => Some(0),
"MD2" | "MD4" | "MD5" => Some(1),
"SHA-1" => Some(2),
"SHA-224" => Some(3),
"SHA-256" | "SHA3-256" | "BLAKE2b-256" | "BLAKE3" | "MD6" => Some(4),
"SHA-384" | "SHA3-384" | "BLAKE2b-384" => Some(5),
"SHA-512" | "SHA3-512" | "BLAKE2b-512" => Some(6),
_ => None,
}
}
pub fn is_hash_algorithm_downgrade(
old_hashes: &BTreeMap<String, String>,
new_hashes: &BTreeMap<String, String>,
) -> bool {
if old_hashes.is_empty() || new_hashes.is_empty() {
return false;
}
let old_max = old_hashes
.keys()
.filter_map(|k| hash_algorithm_strength(k))
.max();
let new_max = new_hashes
.keys()
.filter_map(|k| hash_algorithm_strength(k))
.max();
match (old_max, new_max) {
(Some(old_strength), Some(new_strength)) => new_strength < old_strength,
_ => false,
}
}
pub fn is_copyleft_license(id: &str) -> bool {
spdx::license_id(id)
.map(|l| l.is_copyleft())
.unwrap_or(false)
}
pub fn copyleft_introduced(old: &BTreeSet<String>, new: &BTreeSet<String>) -> bool {
new.iter()
.any(|id| is_copyleft_license(id) && !old.contains(id))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_component_id_purl() {
let purl = "pkg:npm/left-pad@1.3.0";
let id = ComponentId::new(Some(purl), &[]);
assert_eq!(id.as_str(), purl);
}
#[test]
fn test_component_id_hash_stability() {
let props = [("name", "foo"), ("version", "1.0")];
let id1 = ComponentId::new(None, &props);
let id2 = ComponentId::new(None, &props);
assert_eq!(id1, id2);
assert!(id1.as_str().starts_with("h:"));
}
#[test]
fn test_normalization() {
let mut comp = Component::new("test".to_string(), Some("1.0".to_string()));
comp.licenses.insert("MIT".to_string());
comp.licenses.insert("Apache-2.0".to_string());
comp.hashes.insert("SHA-256".to_string(), "ABC".to_string());
comp.normalize();
assert_eq!(
comp.licenses,
BTreeSet::from(["Apache-2.0".to_string(), "MIT".to_string()])
);
assert_eq!(comp.hashes.get("sha-256").unwrap(), "abc");
}
fn licensing<'a>(expression: &'a str, ids: &'a BTreeSet<String>) -> Licensing<'a> {
Licensing {
expression: Some(expression),
ids,
}
}
#[test]
fn test_licensing_satisfiable_operators() {
let ids: BTreeSet<String> = ["Apache-2.0".into(), "MIT".into()].into();
let only_mit = |r: &LicenseRequirement| r.license == "MIT";
assert!(licensing("MIT OR Apache-2.0", &ids).satisfiable(only_mit));
assert!(!licensing("MIT AND Apache-2.0", &ids).satisfiable(only_mit));
let nested: BTreeSet<String> =
["Apache-2.0".into(), "BSD-3-Clause".into(), "MIT".into()].into();
let allowed: BTreeSet<String> = ["BSD-3-Clause".into(), "MIT".into()].into();
assert!(licensing("(MIT OR Apache-2.0) AND BSD-3-Clause", &nested)
.satisfiable(|r| allowed.contains(&r.license)));
assert!(!licensing("(MIT AND Apache-2.0) AND BSD-3-Clause", &nested)
.satisfiable(|r| allowed.contains(&r.license)));
}
#[test]
fn test_licensing_without_expression_is_a_conjunction() {
let ids: BTreeSet<String> = ["Apache-2.0".into(), "MIT".into()].into();
assert!(!Licensing::from_ids(&ids).satisfiable(|r| r.license == "MIT"));
assert!(Licensing::from_ids(&ids).satisfiable(|_| true));
}
#[test]
fn test_licensing_falls_back_on_free_text() {
let ids: BTreeSet<String> = ["Custom License".into()].into();
let free_text = licensing("Custom License", &ids);
assert!(free_text.satisfiable(|r| r.license == "Custom License"));
assert!(!free_text.satisfiable(|_| false));
}
#[test]
fn test_licensing_requirements_keep_exceptions() {
let ids: BTreeSet<String> = ["GPL-2.0-only".into()].into();
let reqs = licensing("GPL-2.0-only WITH Classpath-exception-2.0", &ids).requirements();
assert_eq!(reqs.len(), 1);
let req = reqs.iter().next().unwrap();
assert_eq!(req.license, "GPL-2.0-only");
assert_eq!(req.exception.as_deref(), Some("Classpath-exception-2.0"));
assert_eq!(req.to_string(), "GPL-2.0-only WITH Classpath-exception-2.0");
}
#[test]
fn test_licensing_requirements_keep_or_later() {
let ids: BTreeSet<String> = ["Apache-2.0".into()].into();
let req = licensing("Apache-2.0+", &ids)
.requirements()
.into_iter()
.next()
.unwrap();
assert_eq!(req.license, "Apache-2.0");
assert!(req.or_later);
assert_eq!(req.to_string(), "Apache-2.0+");
}
#[test]
fn test_copyleft_obligations_added_respects_choice() {
let mit: BTreeSet<String> = ["MIT".into()].into();
let both: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
let old = licensing("MIT", &mit);
assert!(
copyleft_obligations_added(old, licensing("MIT OR GPL-3.0-only", &both)).is_empty()
);
assert_eq!(
copyleft_obligations_added(old, licensing("MIT AND GPL-3.0-only", &both)),
BTreeSet::from(["GPL-3.0-only".to_string()])
);
}
#[test]
fn test_copyleft_obligations_added_carried_over() {
let gpl: BTreeSet<String> = ["GPL-3.0-only".into()].into();
let both: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
assert!(copyleft_obligations_added(
licensing("GPL-3.0-only", &gpl),
licensing("GPL-3.0-only AND MIT", &both)
)
.is_empty());
let agpl: BTreeSet<String> = ["AGPL-3.0-only".into()].into();
assert_eq!(
copyleft_obligations_added(
licensing("GPL-3.0-only", &gpl),
licensing("AGPL-3.0-only", &agpl)
),
BTreeSet::from(["AGPL-3.0-only".to_string()])
);
}
#[test]
fn test_copyleft_obligations_added_losing_the_permissive_choice() {
let both: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
let gpl: BTreeSet<String> = ["GPL-3.0-only".into()].into();
assert_eq!(
copyleft_obligations_added(
licensing("MIT OR GPL-3.0-only", &both),
licensing("GPL-3.0-only", &gpl)
),
BTreeSet::from(["GPL-3.0-only".to_string()])
);
}
fn copyleft_added(old: &str, new: &str) -> BTreeSet<String> {
let old_ids = parse_license_expression(old);
let new_ids = parse_license_expression(new);
copyleft_obligations_added(licensing(old, &old_ids), licensing(new, &new_ids))
}
fn licenses(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn test_copyleft_obligations_added_ignores_a_choice_between_copyleft_licenses() {
for (old, new) in [
(
"GPL-2.0-only OR GPL-3.0-only",
"GPL-2.0-only OR GPL-3.0-only OR LGPL-3.0-only",
),
(
"MIT AND (GPL-2.0-only OR GPL-3.0-only)",
"Apache-2.0 AND (GPL-2.0-only OR GPL-3.0-only)",
),
(
"MPL-2.0 OR GPL-2.0-only OR LGPL-2.1-only",
"MPL-2.0 OR GPL-2.0-only OR LGPL-2.1-only",
),
] {
assert!(
copyleft_added(old, new).is_empty(),
"{old} -> {new} forces no copyleft the consumer could not already have taken"
);
}
}
#[test]
fn test_copyleft_obligations_added_fires_on_every_tightening() {
for (old, new, introduced) in [
(
"GPL-2.0-only OR GPL-3.0-only",
"GPL-2.0-only AND GPL-3.0-only",
&["GPL-2.0-only", "GPL-3.0-only"][..],
),
("MIT OR GPL-3.0-only", "GPL-3.0-only", &["GPL-3.0-only"]),
("GPL-3.0-only", "AGPL-3.0-only", &["AGPL-3.0-only"]),
("MIT", "MIT AND GPL-3.0-only", &["GPL-3.0-only"]),
(
"GPL-2.0-only OR GPL-3.0-only",
"AGPL-3.0-only",
&["AGPL-3.0-only"],
),
] {
assert_eq!(
copyleft_added(old, new),
licenses(introduced),
"{old} -> {new}"
);
}
}
#[test]
fn test_copyleft_obligations_added_spans_the_minimal_choices_less_what_old_forced() {
for (old, new, introduced) in [
(
"MIT",
"GPL-3.0-only AND (MPL-2.0 OR ISC)",
&["GPL-3.0-only"][..],
),
(
"GPL-2.0-only",
"GPL-2.0-only AND GPL-3.0-only",
&["GPL-3.0-only"],
),
(
"MIT",
"GPL-2.0-only OR AGPL-3.0-only",
&["AGPL-3.0-only", "GPL-2.0-only"],
),
(
"MIT",
"(GPL-3.0-only AND MPL-2.0) OR (AGPL-3.0-only AND EPL-2.0)",
&["AGPL-3.0-only", "GPL-3.0-only", "MPL-2.0"],
),
] {
assert_eq!(
copyleft_added(old, new),
licenses(introduced),
"{old} -> {new}"
);
}
}
#[test]
fn test_copyleft_obligations_added_falls_back_past_max_choices() {
let wide = "(MIT OR Apache-2.0) AND (BSD-2-Clause OR BSD-3-Clause) \
AND (ISC OR Zlib) AND (MPL-2.0 OR EPL-2.0) \
AND (GPL-2.0-only OR GPL-3.0-only) AND (LGPL-2.1-only OR LGPL-3.0-only) \
AND (CC0-1.0 OR Unlicense)";
assert_eq!(
copyleft_added("MIT", wide),
licenses(&[
"GPL-2.0-only",
"GPL-3.0-only",
"LGPL-2.1-only",
"LGPL-3.0-only",
"MPL-2.0",
])
);
}
#[test]
fn test_copyleft_obligations_added_matches_flat_sets() {
let old: BTreeSet<String> = ["MIT".into()].into();
let new: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
assert_eq!(
copyleft_obligations_added(Licensing::from_ids(&old), Licensing::from_ids(&new)),
BTreeSet::from(["GPL-3.0-only".to_string()])
);
assert!(copyleft_introduced(&old, &new));
assert!(
copyleft_obligations_added(Licensing::from_ids(&new), Licensing::from_ids(&new))
.is_empty()
);
assert!(!copyleft_introduced(&new, &new));
}
#[test]
fn test_license_expressions_equivalent() {
assert!(license_expressions_equivalent(
"MIT OR Apache-2.0",
"( MIT OR (Apache-2.0) )"
));
assert!(!license_expressions_equivalent(
"MIT OR Apache-2.0",
"MIT AND Apache-2.0"
));
assert!(!license_expressions_equivalent(
"GPL-2.0-only",
"GPL-2.0-only WITH Classpath-exception-2.0"
));
assert!(license_expressions_equivalent("Custom Text", "Custom Text"));
assert!(!license_expressions_equivalent("Custom Text", "Other Text"));
}
#[test]
fn test_license_expressions_equivalent_falls_back_past_max_choices() {
let wide = "(MIT OR Apache-2.0) AND (BSD-2-Clause OR BSD-3-Clause) \
AND (ISC OR Zlib) AND (MPL-2.0 OR EPL-2.0) \
AND (GPL-2.0-only OR GPL-3.0-only) AND (LGPL-2.1-only OR LGPL-3.0-only) \
AND (CC0-1.0 OR Unlicense)";
assert!(license_expressions_equivalent(wide, wide));
assert!(license_expressions_equivalent(
wide,
&wide.replace("(MIT OR Apache-2.0)", "((MIT OR Apache-2.0))")
));
assert!(!license_expressions_equivalent(
wide,
&wide.replace("MIT OR Apache-2.0", "Apache-2.0 OR MIT")
));
let ids: BTreeSet<String> = wide
.split_whitespace()
.map(|word| word.trim_matches(['(', ')']).to_string())
.filter(|word| word != "AND" && word != "OR")
.collect();
assert!(!licensings_equivalent(
Licensing {
expression: Some(wide),
ids: &ids,
},
Licensing::from_ids(&ids)
));
}
#[test]
fn test_license_expressions_equivalent_ignores_operand_order() {
assert!(license_expressions_equivalent(
"MIT OR Apache-2.0",
"Apache-2.0 OR MIT"
));
assert!(license_expressions_equivalent(
"(MIT OR Apache-2.0) AND BSD-3-Clause",
"(BSD-3-Clause AND Apache-2.0) OR (BSD-3-Clause AND MIT)"
));
assert!(license_expressions_equivalent(
"MIT",
"MIT OR (MIT AND Apache-2.0)"
));
assert!(!license_expressions_equivalent(
"MIT OR Apache-2.0",
"MIT OR BSD-3-Clause"
));
}
#[test]
fn test_licensings_equivalent_reads_a_bare_set_as_a_conjunction() {
let ids: BTreeSet<String> = ["GPL-3.0-only".to_string(), "MIT".to_string()].into();
let flat = Licensing::from_ids(&ids);
assert!(licensings_equivalent(
Licensing {
expression: Some("MIT AND GPL-3.0-only"),
ids: &ids,
},
flat
));
assert!(!licensings_equivalent(
Licensing {
expression: Some("MIT OR GPL-3.0-only"),
ids: &ids,
},
flat
));
assert!(licensings_equivalent(flat, flat));
}
#[test]
fn test_licensings_equivalent_keeps_decorated_requirements() {
let gpl: BTreeSet<String> = ["GPL-2.0-only".to_string()].into();
assert!(!licensings_equivalent(
Licensing {
expression: Some("GPL-2.0-only WITH Classpath-exception-2.0"),
ids: &gpl,
},
Licensing::from_ids(&gpl)
));
let apache: BTreeSet<String> = ["Apache-2.0".to_string()].into();
assert!(!licensings_equivalent(
Licensing {
expression: Some("Apache-2.0+"),
ids: &apache,
},
Licensing::from_ids(&apache)
));
}
#[test]
fn test_licensings_equivalent_falls_back_to_the_identifier_set() {
let ids: BTreeSet<String> = ["Custom Text".to_string()].into();
assert!(licensings_equivalent(
Licensing {
expression: Some("Custom Text"),
ids: &ids,
},
Licensing::from_ids(&ids)
));
}
#[test]
fn test_component_licensing_defaults_to_ids() {
let mut comp = Component::new("demo".into(), None);
comp.licenses.insert("MIT".into());
assert_eq!(comp.licensing().expression, None);
assert_eq!(
comp.licensing().requirements(),
BTreeSet::from([LicenseRequirement::new("MIT")])
);
}
#[test]
fn test_parse_license_expression() {
let ids = parse_license_expression("MIT OR Apache-2.0");
assert!(ids.contains("MIT"));
assert!(ids.contains("Apache-2.0"));
assert_eq!(ids.len(), 2);
let ids = parse_license_expression("MIT");
assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
let ids = parse_license_expression("MIT AND Apache-2.0");
assert!(ids.contains("MIT"));
assert!(ids.contains("Apache-2.0"));
let ids = parse_license_expression("Custom License");
assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
let ids = parse_license_expression("LicenseRef-proprietary");
assert_eq!(ids, BTreeSet::from(["LicenseRef-proprietary".to_string()]));
}
#[test]
fn test_parse_license_expression_licenseref_and_spdx() {
let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
assert!(ids.contains("LicenseRef-proprietary"));
assert!(ids.contains("Apache-2.0"));
assert_eq!(ids.len(), 2);
}
#[test]
fn test_parse_license_expression_licenseref_or_spdx() {
let ids = parse_license_expression("LicenseRef-custom OR MIT");
assert!(ids.contains("LicenseRef-custom"));
assert!(ids.contains("MIT"));
assert_eq!(ids.len(), 2);
}
#[test]
fn test_parse_license_expression_multiple_licenserefs() {
let ids = parse_license_expression("LicenseRef-a AND LicenseRef-b");
assert!(ids.contains("LicenseRef-a"));
assert!(ids.contains("LicenseRef-b"));
assert_eq!(ids.len(), 2);
}
#[test]
fn test_parse_license_expression_complex_mixed() {
let ids = parse_license_expression("(MIT OR LicenseRef-custom) AND Apache-2.0");
assert!(ids.contains("MIT"));
assert!(ids.contains("LicenseRef-custom"));
assert!(ids.contains("Apache-2.0"));
assert_eq!(ids.len(), 3);
}
#[test]
fn test_parse_license_expression_documentref() {
let ids = parse_license_expression("DocumentRef-ext:LicenseRef-custom");
assert_eq!(
ids,
BTreeSet::from(["DocumentRef-ext:LicenseRef-custom".to_string()])
);
}
#[test]
fn test_license_set_equality() {
let mut c1 = Component::new("test".into(), None);
c1.licenses.insert("MIT".into());
c1.licenses.insert("Apache-2.0".into());
let mut c2 = Component::new("test".into(), None);
c2.licenses.insert("Apache-2.0".into());
c2.licenses.insert("MIT".into());
assert_eq!(c1.licenses, c2.licenses);
}
#[test]
fn test_query_api() {
let mut sbom = Sbom::default();
let c1 = Component::new("a".into(), Some("1".into()));
let c2 = Component::new("b".into(), Some("1".into()));
let c3 = Component::new("c".into(), Some("1".into()));
let id1 = c1.id.clone();
let id2 = c2.id.clone();
let id3 = c3.id.clone();
sbom.components.insert(id1.clone(), c1);
sbom.components.insert(id2.clone(), c2);
sbom.components.insert(id3.clone(), c3);
sbom.dependencies
.entry(id1.clone())
.or_default()
.insert(id2.clone(), DependencyKind::Runtime);
sbom.dependencies
.entry(id2.clone())
.or_default()
.insert(id3.clone(), DependencyKind::Runtime);
sbom.rebuild_reverse_deps();
assert_eq!(sbom.roots(), vec![id1.clone()]);
assert_eq!(sbom.deps(&id1), vec![id2.clone()]);
assert_eq!(sbom.rdeps(&id2), vec![id1.clone()]);
let transitive = sbom.transitive_deps(&id1);
assert!(transitive.contains(&id2));
assert!(transitive.contains(&id3));
assert_eq!(transitive.len(), 2);
assert_eq!(sbom.missing_hashes().len(), 3);
}
#[test]
fn test_ecosystems_query() {
let mut sbom = Sbom::default();
let mut c1 = Component::new("lodash".into(), Some("1.0".into()));
c1.ecosystem = Some("npm".into());
let mut c2 = Component::new("serde".into(), Some("1.0".into()));
c2.ecosystem = Some("cargo".into());
let mut c3 = Component::new("other-npm".into(), Some("1.0".into()));
c3.ecosystem = Some("npm".into());
let c4 = Component::new("no-ecosystem".into(), Some("1.0".into()));
sbom.components.insert(c1.id.clone(), c1);
sbom.components.insert(c2.id.clone(), c2);
sbom.components.insert(c3.id.clone(), c3);
sbom.components.insert(c4.id.clone(), c4);
let ecosystems = sbom.ecosystems();
assert_eq!(ecosystems.len(), 2);
assert!(ecosystems.contains("npm"));
assert!(ecosystems.contains("cargo"));
}
#[test]
fn test_licenses_query() {
let mut sbom = Sbom::default();
let mut c1 = Component::new("a".into(), Some("1.0".into()));
c1.licenses.insert("MIT".into());
c1.licenses.insert("Apache-2.0".into());
let mut c2 = Component::new("b".into(), Some("1.0".into()));
c2.licenses.insert("MIT".into());
c2.licenses.insert("GPL-3.0-only".into());
let c3 = Component::new("c".into(), Some("1.0".into()));
sbom.components.insert(c1.id.clone(), c1);
sbom.components.insert(c2.id.clone(), c2);
sbom.components.insert(c3.id.clone(), c3);
let licenses = sbom.licenses();
assert_eq!(licenses.len(), 3);
assert!(licenses.contains("MIT"));
assert!(licenses.contains("Apache-2.0"));
assert!(licenses.contains("GPL-3.0-only"));
}
#[test]
fn test_by_purl() {
let mut sbom = Sbom::default();
let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
c1.purl = Some("pkg:npm/lodash@4.17.21".into());
c1.id = ComponentId::new(c1.purl.as_deref(), &[]);
let c2 = Component::new("no-purl".into(), Some("1.0".into()));
sbom.components.insert(c1.id.clone(), c1);
sbom.components.insert(c2.id.clone(), c2);
let found = sbom.by_purl("pkg:npm/lodash@4.17.21");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "lodash");
assert!(sbom.by_purl("pkg:npm/nonexistent@1.0").is_none());
}
#[test]
fn test_component_id_unparseable_purl() {
let id = ComponentId::new(Some("not-a-valid-purl-but-still-a-string"), &[]);
assert_eq!(id.as_str(), "not-a-valid-purl-but-still-a-string");
}
#[test]
fn test_component_id_display() {
let id = ComponentId::new(Some("pkg:npm/foo@1.0"), &[]);
assert_eq!(format!("{}", id), "pkg:npm/foo@1.0");
}
#[test]
fn test_sbom_normalize_clears_metadata() {
let mut sbom = Sbom::default();
sbom.metadata.timestamp = Some("2024-01-01T00:00:00Z".into());
sbom.metadata.tools.push("syft".into());
sbom.metadata.authors.push("alice".into());
let c = Component::new("a".into(), Some("1".into()));
sbom.components.insert(c.id.clone(), c);
sbom.normalize();
assert!(sbom.metadata.timestamp.is_none());
assert!(sbom.metadata.tools.is_empty());
assert!(sbom.metadata.authors.is_empty());
}
#[test]
fn test_missing_hashes_mixed() {
let mut sbom = Sbom::default();
let c1 = Component::new("no-hash".into(), Some("1.0".into()));
let mut c2 = Component::new("has-hash".into(), Some("1.0".into()));
c2.hashes.insert("sha256".into(), "abc".into());
sbom.components.insert(c1.id.clone(), c1);
sbom.components.insert(c2.id.clone(), c2);
let missing = sbom.missing_hashes();
assert_eq!(missing.len(), 1);
}
#[test]
fn test_ecosystem_from_purl() {
use super::ecosystem_from_purl;
assert_eq!(
ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
Some("npm".to_string())
);
assert_eq!(
ecosystem_from_purl("pkg:cargo/serde@1.0.0"),
Some("cargo".to_string())
);
assert_eq!(
ecosystem_from_purl("pkg:pypi/requests@2.28.0"),
Some("pypi".to_string())
);
assert_eq!(
ecosystem_from_purl("pkg:maven/org.apache/commons@1.0"),
Some("maven".to_string())
);
assert_eq!(ecosystem_from_purl("invalid-purl"), None);
assert_eq!(ecosystem_from_purl(""), None);
}
#[test]
fn test_canonical_algorithm_name() {
assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
assert_eq!(canonical_algorithm_name("SHA1"), "SHA-1");
assert_eq!(canonical_algorithm_name("SHA384"), "SHA-384");
assert_eq!(canonical_algorithm_name("SHA512"), "SHA-512");
assert_eq!(canonical_algorithm_name("SHA224"), "SHA-224");
assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
assert_eq!(canonical_algorithm_name("SHA-1"), "SHA-1");
assert_eq!(canonical_algorithm_name("SHA-384"), "SHA-384");
assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
assert_eq!(canonical_algorithm_name("MD5"), "MD5");
assert_eq!(canonical_algorithm_name("md5"), "MD5");
assert_eq!(canonical_algorithm_name("BLAKE2b-256"), "BLAKE2b-256");
assert_eq!(canonical_algorithm_name("BLAKE2B256"), "BLAKE2b-256");
assert_eq!(canonical_algorithm_name("BLAKE3"), "BLAKE3");
assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
}
#[test]
fn test_hash_algorithm_strength_ordering() {
let md5 = hash_algorithm_strength("MD5").unwrap();
let sha1 = hash_algorithm_strength("SHA-1").unwrap();
let sha224 = hash_algorithm_strength("SHA-224").unwrap();
let sha256 = hash_algorithm_strength("SHA-256").unwrap();
let sha384 = hash_algorithm_strength("SHA-384").unwrap();
let sha512 = hash_algorithm_strength("SHA-512").unwrap();
assert!(md5 < sha1);
assert!(sha1 < sha224);
assert!(sha224 < sha256);
assert!(sha256 < sha384);
assert!(sha384 < sha512);
}
#[test]
fn test_hash_algorithm_strength_variants() {
assert_eq!(
hash_algorithm_strength("sha256"),
hash_algorithm_strength("SHA-256")
);
assert_eq!(
hash_algorithm_strength("sha-1"),
hash_algorithm_strength("SHA1")
);
assert_eq!(
hash_algorithm_strength("SHA3-256"),
hash_algorithm_strength("SHA-256")
);
assert_eq!(
hash_algorithm_strength("SHA3-512"),
hash_algorithm_strength("SHA-512")
);
assert_eq!(
hash_algorithm_strength("BLAKE2b-256"),
hash_algorithm_strength("SHA-256")
);
assert_eq!(
hash_algorithm_strength("BLAKE3"),
hash_algorithm_strength("SHA-256")
);
assert_eq!(hash_algorithm_strength("TIGER"), None);
assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
}
#[test]
fn test_hash_algorithm_strength_adler() {
let adler = hash_algorithm_strength("ADLER-32").unwrap();
let md5 = hash_algorithm_strength("MD5").unwrap();
assert!(adler < md5);
}
#[test]
fn test_is_hash_algorithm_downgrade_sha256_to_md5() {
let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
assert!(is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_is_hash_algorithm_downgrade_upgrade_not_flagged() {
let old: BTreeMap<String, String> = [("sha-1".into(), "abc".into())].into();
let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
assert!(!is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_is_hash_algorithm_downgrade_same_algorithm() {
let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
assert!(!is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_is_hash_algorithm_downgrade_empty_old() {
let old: BTreeMap<String, String> = BTreeMap::new();
let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
assert!(!is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_is_hash_algorithm_downgrade_empty_new() {
let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
let new: BTreeMap<String, String> = BTreeMap::new();
assert!(!is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_is_hash_algorithm_downgrade_multi_algorithm() {
let old: BTreeMap<String, String> = [
("sha-256".into(), "abc".into()),
("md5".into(), "xyz".into()),
]
.into();
let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
assert!(is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_is_hash_algorithm_downgrade_multi_algorithm_kept() {
let old: BTreeMap<String, String> = [
("sha-256".into(), "abc".into()),
("md5".into(), "xyz".into()),
]
.into();
let new: BTreeMap<String, String> = [
("sha-256".into(), "def".into()),
("sha-1".into(), "ghi".into()),
]
.into();
assert!(!is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_detect_cycles_none() {
let mut sbom = Sbom::default();
let c1 = Component::new("a".into(), Some("1".into()));
let c2 = Component::new("b".into(), Some("1".into()));
let c3 = Component::new("c".into(), Some("1".into()));
let id1 = c1.id.clone();
let id2 = c2.id.clone();
let id3 = c3.id.clone();
sbom.components.insert(id1.clone(), c1);
sbom.components.insert(id2.clone(), c2);
sbom.components.insert(id3.clone(), c3);
sbom.dependencies
.entry(id1.clone())
.or_default()
.insert(id2.clone(), DependencyKind::Runtime);
sbom.dependencies
.entry(id2.clone())
.or_default()
.insert(id3.clone(), DependencyKind::Runtime);
assert!(sbom.detect_cycles().is_empty());
}
#[test]
fn test_detect_cycles_simple() {
let mut sbom = Sbom::default();
let c1 = Component::new("a".into(), Some("1".into()));
let c2 = Component::new("b".into(), Some("1".into()));
let id1 = c1.id.clone();
let id2 = c2.id.clone();
sbom.components.insert(id1.clone(), c1);
sbom.components.insert(id2.clone(), c2);
sbom.dependencies
.entry(id1.clone())
.or_default()
.insert(id2.clone(), DependencyKind::Runtime);
sbom.dependencies
.entry(id2.clone())
.or_default()
.insert(id1.clone(), DependencyKind::Runtime);
let cycles = sbom.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].first(), cycles[0].last());
}
#[test]
fn test_detect_cycles_self_loop() {
let mut sbom = Sbom::default();
let c1 = Component::new("a".into(), Some("1".into()));
let id1 = c1.id.clone();
sbom.components.insert(id1.clone(), c1);
sbom.dependencies
.entry(id1.clone())
.or_default()
.insert(id1.clone(), DependencyKind::Runtime);
let cycles = sbom.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].len(), 2); }
#[test]
fn test_detect_cycles_empty_graph() {
let sbom = Sbom::default();
assert!(sbom.detect_cycles().is_empty());
}
#[test]
fn test_detect_cycles_three_node() {
let mut sbom = Sbom::default();
let c1 = Component::new("a".into(), Some("1".into()));
let c2 = Component::new("b".into(), Some("1".into()));
let c3 = Component::new("c".into(), Some("1".into()));
let id1 = c1.id.clone();
let id2 = c2.id.clone();
let id3 = c3.id.clone();
sbom.components.insert(id1.clone(), c1);
sbom.components.insert(id2.clone(), c2);
sbom.components.insert(id3.clone(), c3);
sbom.dependencies
.entry(id1.clone())
.or_default()
.insert(id2.clone(), DependencyKind::Runtime);
sbom.dependencies
.entry(id2.clone())
.or_default()
.insert(id3.clone(), DependencyKind::Runtime);
sbom.dependencies
.entry(id3.clone())
.or_default()
.insert(id1.clone(), DependencyKind::Runtime);
let cycles = sbom.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].first(), cycles[0].last());
assert_eq!(cycles[0].len(), 4); }
#[test]
fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
let old: BTreeMap<String, String> = [("TIGER".into(), "abc".into())].into();
let new: BTreeMap<String, String> = [("WHIRLPOOL".into(), "def".into())].into();
assert!(!is_hash_algorithm_downgrade(&old, &new));
}
#[test]
fn test_is_copyleft_license() {
assert!(is_copyleft_license("GPL-3.0-only"));
assert!(is_copyleft_license("AGPL-3.0-only"));
assert!(is_copyleft_license("LGPL-3.0-only"));
assert!(!is_copyleft_license("MIT"));
assert!(!is_copyleft_license("Apache-2.0"));
assert!(!is_copyleft_license("BSD-3-Clause"));
assert!(!is_copyleft_license("LicenseRef-proprietary"));
assert!(!is_copyleft_license("NOT-A-LICENSE"));
}
#[test]
fn test_copyleft_introduced_permissive_to_copyleft() {
let old: BTreeSet<String> = ["MIT".into()].into();
let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
assert!(copyleft_introduced(&old, &new));
}
#[test]
fn test_copyleft_introduced_permissive_to_permissive() {
let old: BTreeSet<String> = ["MIT".into()].into();
let new: BTreeSet<String> = ["Apache-2.0".into()].into();
assert!(!copyleft_introduced(&old, &new));
}
#[test]
fn test_copyleft_introduced_carried_over_not_flagged() {
let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
assert!(!copyleft_introduced(&old, &new));
}
#[test]
fn test_copyleft_introduced_added_alongside_existing() {
let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
let new: BTreeSet<String> = ["GPL-3.0-only".into(), "AGPL-3.0-only".into()].into();
assert!(copyleft_introduced(&old, &new));
}
}