Skip to main content

sbom_model/
lib.rs

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/// format-agnostic SBOM (Software Bill of Materials) representation.
14///
15/// this is the central type that holds all components and their relationships.
16/// it abstracts over format-specific details from CycloneDX, SPDX, and other formats.
17///
18/// # Example
19///
20/// ```
21/// use sbom_model::{Sbom, Component};
22///
23/// let mut sbom = Sbom::default();
24/// let component = Component::new("serde".into(), Some("1.0.0".into()));
25/// sbom.components.insert(component.id.clone(), component);
26/// ```
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Sbom {
29    /// document-level metadata (creation time, tools, authors).
30    pub metadata: Metadata,
31    /// all components indexed by their stable identifier.
32    pub components: IndexMap<ComponentId, Component>,
33    /// dependency graph as adjacency list: parent -> (child -> kind).
34    pub dependencies: BTreeMap<ComponentId, BTreeMap<ComponentId, DependencyKind>>,
35    /// reverse dependency index: child -> set of parents.
36    ///
37    /// derived from `dependencies`; call [`rebuild_reverse_deps`](Sbom::rebuild_reverse_deps)
38    /// after modifying `dependencies` to keep it in sync.
39    #[serde(skip)]
40    pub reverse_deps: BTreeMap<ComponentId, BTreeSet<ComponentId>>,
41    /// non-fatal warnings produced during parsing (e.g. orphaned dependency refs).
42    #[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/// SBOM document metadata.
70///
71/// contains information about when and how the SBOM was created.
72/// this data is stripped during normalization since it varies between
73/// tool runs and shouldn't affect diff comparisons.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
75pub struct Metadata {
76    /// ISO 8601 timestamp of document creation.
77    pub timestamp: Option<String>,
78    /// tools used to generate the SBOM (e.g., "syft", "trivy").
79    pub tools: Vec<String>,
80    /// document authors or organizations.
81    pub authors: Vec<String>,
82}
83
84/// the semantic type of a dependency relationship.
85///
86/// SPDX distinguishes between runtime, dev, build, test, optional, and
87/// provided dependencies via typed relationship names. CycloneDX encodes
88/// scope on the component itself (`required` / `optional` / `excluded`),
89/// which is mapped to the appropriate variant when constructing edges.
90///
91/// the default is `Runtime`, which also covers generic relationships
92/// like `DEPENDS_ON` or `CONTAINS` that don't specify a scope.
93#[derive(
94    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
95)]
96#[serde(rename_all = "lowercase")]
97pub enum DependencyKind {
98    /// runtime or unspecified dependency (the default).
99    #[default]
100    Runtime,
101    /// development-only dependency.
102    Dev,
103    /// build-time dependency.
104    Build,
105    /// test-only dependency.
106    Test,
107    /// optional dependency.
108    Optional,
109    /// provided by the runtime environment.
110    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/// stable identifier for a component.
127///
128/// used as a key in the component map and dependency graph. prefers package URLs
129/// (purls) when available since they provide globally unique identifiers. falls
130/// back to a deterministic SHA-256 hash of component properties when no purl exists.
131///
132/// # Example
133///
134/// ```
135/// use sbom_model::ComponentId;
136///
137/// // with a purl (preferred)
138/// let id = ComponentId::new(Some("pkg:npm/lodash@4.17.21"), &[]);
139/// assert_eq!(id.as_str(), "pkg:npm/lodash@4.17.21");
140///
141/// // without a purl (hash fallback)
142/// let id = ComponentId::new(None, &[("name", "foo"), ("version", "1.0")]);
143/// assert!(id.as_str().starts_with("h:"));
144/// ```
145#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
146pub struct ComponentId(String);
147
148impl ComponentId {
149    /// creates a new identifier from a purl or property hash.
150    ///
151    /// if a purl is provided, it will be canonicalized. otherwise, a deterministic
152    /// SHA-256 hash is computed from the provided key-value properties.
153    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        // deterministic hash fallback
162        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    /// returns the identifier as a string slice.
174    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/// a software component (package, library, or application).
186///
187/// represents a single entry in the SBOM with all its metadata.
188/// components are identified by their [`ComponentId`] and can have
189/// relationships to other components via the dependency graph.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Component {
192    /// stable identifier for this component.
193    pub id: ComponentId,
194    /// package name (e.g., "serde", "lodash").
195    pub name: String,
196    /// package version (e.g., "1.0.0", "4.17.21").
197    pub version: Option<String>,
198    /// package ecosystem (e.g., "cargo", "npm", "pypi").
199    pub ecosystem: Option<String>,
200    /// package supplier or publisher.
201    pub supplier: Option<String>,
202    /// human-readable description.
203    pub description: Option<String>,
204    /// package URL per the [purl spec](https://github.com/package-url/purl-spec).
205    pub purl: Option<String>,
206    /// SPDX license identifiers (e.g., "MIT", "Apache-2.0").
207    pub licenses: BTreeSet<String>,
208    /// checksums keyed by algorithm (e.g., "sha256" -> "abc123...").
209    pub hashes: BTreeMap<String, String>,
210    /// original identifiers from the source document (e.g., SPDX SPDXRef, CycloneDX bom-ref).
211    pub source_ids: Vec<String>,
212}
213
214impl Component {
215    /// creates a new component with the given name and optional version.
216    ///
217    /// the component ID is generated from a hash of the name and version.
218    /// use this for simple cases; for full control, construct the struct directly.
219    pub fn new(name: String, version: Option<String>) -> Self {
220        let mut props = vec![("name", name.as_str())];
221        if let Some(v) = &version {
222            props.push(("version", v));
223        }
224        let id = ComponentId::new(None, &props);
225
226        Self {
227            id,
228            name,
229            version,
230            ecosystem: None,
231            supplier: None,
232            description: None,
233            purl: None,
234            licenses: BTreeSet::new(),
235            hashes: BTreeMap::new(),
236            source_ids: Vec::new(),
237        }
238    }
239}
240
241impl Sbom {
242    /// normalizes the SBOM for deterministic comparison.
243    ///
244    /// this method:
245    /// - sorts components by ID
246    /// - deduplicates and sorts licenses within each component
247    /// - lowercases hash algorithms and values
248    /// - clears volatile metadata (timestamps, tools, authors)
249    ///
250    /// call this before comparing two SBOMs to ignore irrelevant differences.
251    pub fn normalize(&mut self) {
252        // sort components by ID for deterministic output
253        self.components.sort_keys();
254
255        // normalize components
256        for component in self.components.values_mut() {
257            component.normalize();
258        }
259
260        // strip volatile metadata
261        self.metadata.timestamp = None;
262        self.metadata.tools.clear();
263        self.metadata.authors.clear();
264
265        self.rebuild_reverse_deps();
266    }
267
268    /// rebuilds the reverse dependency index from the forward `dependencies` map.
269    ///
270    /// must be called after modifying `dependencies` for `rdeps()` and `roots()`
271    /// to return correct results. Parsers call this automatically; call it
272    /// explicitly when constructing an `Sbom` by hand.
273    pub fn rebuild_reverse_deps(&mut self) {
274        self.reverse_deps.clear();
275        for (parent, children) in &self.dependencies {
276            for child in children.keys() {
277                self.reverse_deps
278                    .entry(child.clone())
279                    .or_default()
280                    .insert(parent.clone());
281            }
282        }
283    }
284
285    /// returns root components (those not depended on by any other component).
286    ///
287    /// these are typically the top-level packages or applications in the SBOM.
288    /// uses the precomputed `reverse_deps` index.
289    pub fn roots(&self) -> Vec<ComponentId> {
290        self.components
291            .keys()
292            .filter(|id| self.reverse_deps.get(*id).is_none_or(BTreeSet::is_empty))
293            .cloned()
294            .collect()
295    }
296
297    /// returns direct dependencies of the given component.
298    pub fn deps(&self, id: &ComponentId) -> Vec<ComponentId> {
299        self.dependencies
300            .get(id)
301            .map(|d| d.keys().cloned().collect())
302            .unwrap_or_default()
303    }
304
305    /// returns reverse dependencies (components that depend on the given component).
306    /// uses the precomputed `reverse_deps` index.
307    pub fn rdeps(&self, id: &ComponentId) -> Vec<ComponentId> {
308        self.reverse_deps
309            .get(id)
310            .map(|parents| parents.iter().cloned().collect())
311            .unwrap_or_default()
312    }
313
314    /// returns all transitive dependencies of the given component.
315    ///
316    /// traverses the dependency graph depth-first and returns all reachable components.
317    pub fn transitive_deps(&self, id: &ComponentId) -> BTreeSet<ComponentId> {
318        let mut visited = BTreeSet::new();
319        let mut stack = vec![id.clone()];
320        while let Some(current) = stack.pop() {
321            if let Some(children) = self.dependencies.get(&current) {
322                for child in children.keys() {
323                    if visited.insert(child.clone()) {
324                        stack.push(child.clone());
325                    }
326                }
327            }
328        }
329        visited
330    }
331
332    /// returns all unique ecosystems present in the SBOM.
333    pub fn ecosystems(&self) -> BTreeSet<String> {
334        self.components
335            .values()
336            .filter_map(|c| c.ecosystem.clone())
337            .collect()
338    }
339
340    /// returns all unique licenses present across all components.
341    pub fn licenses(&self) -> BTreeSet<String> {
342        self.components
343            .values()
344            .flat_map(|c| c.licenses.iter().cloned())
345            .collect()
346    }
347
348    /// returns components that have no checksums/hashes.
349    ///
350    /// useful for identifying components that may need integrity verification.
351    pub fn missing_hashes(&self) -> Vec<ComponentId> {
352        self.components
353            .iter()
354            .filter(|(_, c)| c.hashes.is_empty())
355            .map(|(id, _)| id.clone())
356            .collect()
357    }
358
359    /// finds a component by its package URL.
360    pub fn by_purl(&self, purl: &str) -> Option<&Component> {
361        let id = ComponentId::new(Some(purl), &[]);
362        self.components.get(&id)
363    }
364
365    /// detects dependency cycles in the SBOM's dependency graph.
366    ///
367    /// uses iterative stack-based depth-first search with three-color marking
368    /// (white/gray/black) to find all distinct cycles. each returned vector
369    /// contains the component IDs forming a cycle, starting and ending with
370    /// the same ID.
371    ///
372    /// returns an empty vector if the graph is acyclic.
373    pub fn detect_cycles(&self) -> Vec<Vec<ComponentId>> {
374        enum Frame {
375            Enter(ComponentId),
376            Exit(ComponentId),
377        }
378
379        let mut visited = BTreeSet::new();
380        let mut on_stack = BTreeSet::new();
381        let mut path = Vec::new();
382        let mut cycles = Vec::new();
383
384        let mut stack: Vec<Frame> = self
385            .dependencies
386            .keys()
387            .rev()
388            .map(|k| Frame::Enter(k.clone()))
389            .collect();
390
391        while let Some(frame) = stack.pop() {
392            match frame {
393                Frame::Enter(node) => {
394                    if visited.contains(&node) {
395                        continue;
396                    }
397                    visited.insert(node.clone());
398                    on_stack.insert(node.clone());
399                    path.push(node.clone());
400                    stack.push(Frame::Exit(node.clone()));
401
402                    if let Some(children) = self.dependencies.get(&node) {
403                        for child in children.keys().rev() {
404                            if !visited.contains(child) {
405                                stack.push(Frame::Enter(child.clone()));
406                            } else if on_stack.contains(child) {
407                                if let Some(start) = path.iter().position(|n| n == child) {
408                                    let mut cycle: Vec<_> = path[start..].to_vec();
409                                    cycle.push(child.clone());
410                                    cycles.push(cycle);
411                                }
412                            }
413                        }
414                    }
415                }
416                Frame::Exit(node) => {
417                    path.pop();
418                    on_stack.remove(&node);
419                }
420            }
421        }
422
423        cycles
424    }
425}
426
427impl Component {
428    /// normalizes the component for deterministic comparison.
429    ///
430    /// lowercases hash keys and values. licenses are stored as a BTreeSet
431    /// so they're already sorted and deduplicated.
432    pub fn normalize(&mut self) {
433        let normalized_hashes: BTreeMap<String, String> = self
434            .hashes
435            .iter()
436            .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
437            .collect();
438        self.hashes = normalized_hashes;
439    }
440}
441
442/// extracts the ecosystem (package type) from a purl string.
443///
444/// returns `None` if the purl is invalid or cannot be parsed.
445///
446/// # Example
447///
448/// ```
449/// use sbom_model::ecosystem_from_purl;
450///
451/// assert_eq!(ecosystem_from_purl("pkg:npm/lodash@4.17.21"), Some("npm".to_string()));
452/// assert_eq!(ecosystem_from_purl("pkg:cargo/serde@1.0.0"), Some("cargo".to_string()));
453/// assert_eq!(ecosystem_from_purl("invalid"), None);
454/// ```
455pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
456    PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
457}
458
459/// extracts individual license IDs from an SPDX expression.
460///
461/// parses the expression and returns all license IDs found, including
462/// `LicenseRef-` identifiers. if parsing fails, returns the original
463/// string as a single-element set.
464///
465/// # Example
466///
467/// ```
468/// use sbom_model::parse_license_expression;
469///
470/// let ids = parse_license_expression("MIT OR Apache-2.0");
471/// assert!(ids.contains("MIT"));
472/// assert!(ids.contains("Apache-2.0"));
473///
474/// let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
475/// assert!(ids.contains("LicenseRef-proprietary"));
476/// assert!(ids.contains("Apache-2.0"));
477/// ```
478pub fn parse_license_expression(license: &str) -> BTreeSet<String> {
479    match spdx::Expression::parse(license) {
480        Ok(expr) => {
481            let ids: BTreeSet<String> = expr
482                .requirements()
483                .map(|r| match &r.req.license {
484                    spdx::LicenseItem::Spdx { id, .. } => id.name.to_string(),
485                    other => other.to_string(),
486                })
487                .collect();
488            if ids.is_empty() {
489                // expression parsed but no IDs found, keep original
490                BTreeSet::from([license.to_string()])
491            } else {
492                ids
493            }
494        }
495        Err(_) => {
496            // not a valid SPDX expression, keep original
497            BTreeSet::from([license.to_string()])
498        }
499    }
500}
501
502/// normalizes a hash algorithm name to its canonical form.
503///
504/// handles variations in casing and hyphenation so that algorithm names
505/// from different SBOM formats (SPDX, CycloneDX) compare equal.
506///
507/// # Example
508///
509/// ```
510/// use sbom_model::canonical_algorithm_name;
511///
512/// assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
513/// assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
514/// assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
515/// ```
516pub fn canonical_algorithm_name(name: &str) -> String {
517    match name.replace('-', "").to_uppercase().as_str() {
518        "MD2" => "MD2",
519        "MD4" => "MD4",
520        "MD5" => "MD5",
521        "MD6" => "MD6",
522        "SHA1" => "SHA-1",
523        "SHA224" => "SHA-224",
524        "SHA256" => "SHA-256",
525        "SHA384" => "SHA-384",
526        "SHA512" => "SHA-512",
527        "SHA3256" => "SHA3-256",
528        "SHA3384" => "SHA3-384",
529        "SHA3512" => "SHA3-512",
530        "BLAKE2B256" => "BLAKE2b-256",
531        "BLAKE2B384" => "BLAKE2b-384",
532        "BLAKE2B512" => "BLAKE2b-512",
533        "BLAKE3" => "BLAKE3",
534        "ADLER32" => "ADLER-32",
535        _ => return name.to_string(),
536    }
537    .to_string()
538}
539
540/// returns the strength tier of a hash algorithm, where higher values
541/// indicate stronger algorithms.
542///
543/// returns `None` for unrecognized algorithms. The tiers are:
544/// - 0: Non-cryptographic checksums (ADLER-32)
545/// - 1: Broken cryptographic hashes (MD2, MD4, MD5)
546/// - 2: Weak cryptographic hashes (SHA-1)
547/// - 3: 112-bit security (SHA-224)
548/// - 4: 128-bit security (SHA-256, SHA3-256, BLAKE2b-256, BLAKE3, MD6)
549/// - 5: 192-bit security (SHA-384, SHA3-384, BLAKE2b-384)
550/// - 6: 256-bit security (SHA-512, SHA3-512, BLAKE2b-512)
551///
552/// # Example
553///
554/// ```
555/// use sbom_model::hash_algorithm_strength;
556///
557/// assert!(hash_algorithm_strength("SHA-256").unwrap() > hash_algorithm_strength("MD5").unwrap());
558/// assert!(hash_algorithm_strength("SHA-512").unwrap() > hash_algorithm_strength("SHA-256").unwrap());
559/// assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
560/// ```
561pub fn hash_algorithm_strength(name: &str) -> Option<u8> {
562    let canonical = canonical_algorithm_name(name);
563    match canonical.as_str() {
564        "ADLER-32" => Some(0),
565        "MD2" | "MD4" | "MD5" => Some(1),
566        "SHA-1" => Some(2),
567        "SHA-224" => Some(3),
568        "SHA-256" | "SHA3-256" | "BLAKE2b-256" | "BLAKE3" | "MD6" => Some(4),
569        "SHA-384" | "SHA3-384" | "BLAKE2b-384" => Some(5),
570        "SHA-512" | "SHA3-512" | "BLAKE2b-512" => Some(6),
571        _ => None,
572    }
573}
574
575/// detects whether the hash algorithms in a component were downgraded.
576///
577/// compares the strongest known algorithm in `old_hashes` against the
578/// strongest known algorithm in `new_hashes`. Returns `true` if the new
579/// set's strongest algorithm is weaker than the old set's strongest.
580///
581/// returns `false` when:
582/// - either hash set is empty (use `missing-hashes` for that)
583/// - neither set contains a recognized algorithm
584/// - the new set is at least as strong as the old set
585///
586/// # Example
587///
588/// ```
589/// use sbom_model::is_hash_algorithm_downgrade;
590/// use std::collections::BTreeMap;
591///
592/// let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
593/// let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
594/// assert!(is_hash_algorithm_downgrade(&old, &new));
595///
596/// let new_strong: BTreeMap<String, String> = [("sha-512".into(), "ghi".into())].into();
597/// assert!(!is_hash_algorithm_downgrade(&old, &new_strong));
598/// ```
599pub fn is_hash_algorithm_downgrade(
600    old_hashes: &BTreeMap<String, String>,
601    new_hashes: &BTreeMap<String, String>,
602) -> bool {
603    if old_hashes.is_empty() || new_hashes.is_empty() {
604        return false;
605    }
606
607    let old_max = old_hashes
608        .keys()
609        .filter_map(|k| hash_algorithm_strength(k))
610        .max();
611    let new_max = new_hashes
612        .keys()
613        .filter_map(|k| hash_algorithm_strength(k))
614        .max();
615
616    match (old_max, new_max) {
617        (Some(old_strength), Some(new_strength)) => new_strength < old_strength,
618        _ => false,
619    }
620}
621
622/// classifies an SPDX license identifier as copyleft.
623///
624/// looks the ID up in the compile-time SPDX license list and returns whether
625/// it carries the copyleft flag (the GPL/AGPL/LGPL family, MPL, etc.).
626///
627/// returns `false` for anything SPDX doesn't recognize — `LicenseRef-`
628/// identifiers, full license expressions, and free-text names — a conservative
629/// default so unknown terms never trip a copyleft gate.
630///
631/// # Example
632///
633/// ```
634/// use sbom_model::is_copyleft_license;
635///
636/// assert!(is_copyleft_license("GPL-3.0-only"));
637/// assert!(is_copyleft_license("AGPL-3.0-only"));
638/// assert!(!is_copyleft_license("MIT"));
639/// assert!(!is_copyleft_license("LicenseRef-proprietary"));
640/// ```
641pub fn is_copyleft_license(id: &str) -> bool {
642    spdx::license_id(id)
643        .map(|l| l.is_copyleft())
644        .unwrap_or(false)
645}
646
647/// detects whether a copyleft license was newly introduced between two license sets.
648///
649/// returns `true` iff `new` contains a copyleft license (per
650/// [`is_copyleft_license`]) that is not present in `old`. A copyleft license
651/// carried over from `old` is not an introduction, and permissive-only changes
652/// never fire.
653///
654/// # Example
655///
656/// ```
657/// use sbom_model::copyleft_introduced;
658/// use std::collections::BTreeSet;
659///
660/// let old: BTreeSet<String> = ["MIT".into()].into();
661/// let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
662/// assert!(copyleft_introduced(&old, &new));
663///
664/// let permissive: BTreeSet<String> = ["Apache-2.0".into()].into();
665/// assert!(!copyleft_introduced(&old, &permissive));
666/// ```
667pub fn copyleft_introduced(old: &BTreeSet<String>, new: &BTreeSet<String>) -> bool {
668    new.iter()
669        .any(|id| is_copyleft_license(id) && !old.contains(id))
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn test_component_id_purl() {
678        let purl = "pkg:npm/left-pad@1.3.0";
679        let id = ComponentId::new(Some(purl), &[]);
680        assert_eq!(id.as_str(), purl);
681    }
682
683    #[test]
684    fn test_component_id_hash_stability() {
685        let props = [("name", "foo"), ("version", "1.0")];
686        let id1 = ComponentId::new(None, &props);
687        let id2 = ComponentId::new(None, &props);
688        assert_eq!(id1, id2);
689        assert!(id1.as_str().starts_with("h:"));
690    }
691
692    #[test]
693    fn test_normalization() {
694        let mut comp = Component::new("test".to_string(), Some("1.0".to_string()));
695        comp.licenses.insert("MIT".to_string());
696        comp.licenses.insert("Apache-2.0".to_string());
697        comp.hashes.insert("SHA-256".to_string(), "ABC".to_string());
698
699        comp.normalize();
700
701        assert_eq!(
702            comp.licenses,
703            BTreeSet::from(["Apache-2.0".to_string(), "MIT".to_string()])
704        );
705        assert_eq!(comp.hashes.get("sha-256").unwrap(), "abc");
706    }
707
708    #[test]
709    fn test_parse_license_expression() {
710        // OR expression extracts both IDs
711        let ids = parse_license_expression("MIT OR Apache-2.0");
712        assert!(ids.contains("MIT"));
713        assert!(ids.contains("Apache-2.0"));
714        assert_eq!(ids.len(), 2);
715
716        // single license
717        let ids = parse_license_expression("MIT");
718        assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
719
720        // AND expression extracts both IDs
721        let ids = parse_license_expression("MIT AND Apache-2.0");
722        assert!(ids.contains("MIT"));
723        assert!(ids.contains("Apache-2.0"));
724
725        // invalid expression kept as-is
726        let ids = parse_license_expression("Custom License");
727        assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
728
729        // pure LicenseRef
730        let ids = parse_license_expression("LicenseRef-proprietary");
731        assert_eq!(ids, BTreeSet::from(["LicenseRef-proprietary".to_string()]));
732    }
733
734    #[test]
735    fn test_parse_license_expression_licenseref_and_spdx() {
736        // mixed LicenseRef + SPDX-ID with AND: both must be extracted
737        let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
738        assert!(ids.contains("LicenseRef-proprietary"));
739        assert!(ids.contains("Apache-2.0"));
740        assert_eq!(ids.len(), 2);
741    }
742
743    #[test]
744    fn test_parse_license_expression_licenseref_or_spdx() {
745        // mixed LicenseRef + SPDX-ID with OR
746        let ids = parse_license_expression("LicenseRef-custom OR MIT");
747        assert!(ids.contains("LicenseRef-custom"));
748        assert!(ids.contains("MIT"));
749        assert_eq!(ids.len(), 2);
750    }
751
752    #[test]
753    fn test_parse_license_expression_multiple_licenserefs() {
754        // multiple LicenseRef terms
755        let ids = parse_license_expression("LicenseRef-a AND LicenseRef-b");
756        assert!(ids.contains("LicenseRef-a"));
757        assert!(ids.contains("LicenseRef-b"));
758        assert_eq!(ids.len(), 2);
759    }
760
761    #[test]
762    fn test_parse_license_expression_complex_mixed() {
763        // complex expression mixing LicenseRef and standard IDs
764        let ids = parse_license_expression("(MIT OR LicenseRef-custom) AND Apache-2.0");
765        assert!(ids.contains("MIT"));
766        assert!(ids.contains("LicenseRef-custom"));
767        assert!(ids.contains("Apache-2.0"));
768        assert_eq!(ids.len(), 3);
769    }
770
771    #[test]
772    fn test_parse_license_expression_documentref() {
773        // DocumentRef-prefixed LicenseRef
774        let ids = parse_license_expression("DocumentRef-ext:LicenseRef-custom");
775        assert_eq!(
776            ids,
777            BTreeSet::from(["DocumentRef-ext:LicenseRef-custom".to_string()])
778        );
779    }
780
781    #[test]
782    fn test_license_set_equality() {
783        // two components with same licenses in different order are equal
784        let mut c1 = Component::new("test".into(), None);
785        c1.licenses.insert("MIT".into());
786        c1.licenses.insert("Apache-2.0".into());
787
788        let mut c2 = Component::new("test".into(), None);
789        c2.licenses.insert("Apache-2.0".into());
790        c2.licenses.insert("MIT".into());
791
792        assert_eq!(c1.licenses, c2.licenses);
793    }
794
795    #[test]
796    fn test_query_api() {
797        let mut sbom = Sbom::default();
798        let c1 = Component::new("a".into(), Some("1".into()));
799        let c2 = Component::new("b".into(), Some("1".into()));
800        let c3 = Component::new("c".into(), Some("1".into()));
801
802        let id1 = c1.id.clone();
803        let id2 = c2.id.clone();
804        let id3 = c3.id.clone();
805
806        sbom.components.insert(id1.clone(), c1);
807        sbom.components.insert(id2.clone(), c2);
808        sbom.components.insert(id3.clone(), c3);
809
810        // id1 -> id2 -> id3
811        sbom.dependencies
812            .entry(id1.clone())
813            .or_default()
814            .insert(id2.clone(), DependencyKind::Runtime);
815        sbom.dependencies
816            .entry(id2.clone())
817            .or_default()
818            .insert(id3.clone(), DependencyKind::Runtime);
819        sbom.rebuild_reverse_deps();
820
821        assert_eq!(sbom.roots(), vec![id1.clone()]);
822        assert_eq!(sbom.deps(&id1), vec![id2.clone()]);
823        assert_eq!(sbom.rdeps(&id2), vec![id1.clone()]);
824
825        let transitive = sbom.transitive_deps(&id1);
826        assert!(transitive.contains(&id2));
827        assert!(transitive.contains(&id3));
828        assert_eq!(transitive.len(), 2);
829
830        assert_eq!(sbom.missing_hashes().len(), 3);
831    }
832
833    #[test]
834    fn test_ecosystems_query() {
835        let mut sbom = Sbom::default();
836
837        let mut c1 = Component::new("lodash".into(), Some("1.0".into()));
838        c1.ecosystem = Some("npm".into());
839        let mut c2 = Component::new("serde".into(), Some("1.0".into()));
840        c2.ecosystem = Some("cargo".into());
841        let mut c3 = Component::new("other-npm".into(), Some("1.0".into()));
842        c3.ecosystem = Some("npm".into());
843        let c4 = Component::new("no-ecosystem".into(), Some("1.0".into()));
844
845        sbom.components.insert(c1.id.clone(), c1);
846        sbom.components.insert(c2.id.clone(), c2);
847        sbom.components.insert(c3.id.clone(), c3);
848        sbom.components.insert(c4.id.clone(), c4);
849
850        let ecosystems = sbom.ecosystems();
851        assert_eq!(ecosystems.len(), 2);
852        assert!(ecosystems.contains("npm"));
853        assert!(ecosystems.contains("cargo"));
854    }
855
856    #[test]
857    fn test_licenses_query() {
858        let mut sbom = Sbom::default();
859
860        let mut c1 = Component::new("a".into(), Some("1.0".into()));
861        c1.licenses.insert("MIT".into());
862        c1.licenses.insert("Apache-2.0".into());
863        let mut c2 = Component::new("b".into(), Some("1.0".into()));
864        c2.licenses.insert("MIT".into());
865        c2.licenses.insert("GPL-3.0-only".into());
866        let c3 = Component::new("c".into(), Some("1.0".into()));
867
868        sbom.components.insert(c1.id.clone(), c1);
869        sbom.components.insert(c2.id.clone(), c2);
870        sbom.components.insert(c3.id.clone(), c3);
871
872        let licenses = sbom.licenses();
873        assert_eq!(licenses.len(), 3);
874        assert!(licenses.contains("MIT"));
875        assert!(licenses.contains("Apache-2.0"));
876        assert!(licenses.contains("GPL-3.0-only"));
877    }
878
879    #[test]
880    fn test_by_purl() {
881        let mut sbom = Sbom::default();
882
883        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
884        c1.purl = Some("pkg:npm/lodash@4.17.21".into());
885        c1.id = ComponentId::new(c1.purl.as_deref(), &[]);
886        let c2 = Component::new("no-purl".into(), Some("1.0".into()));
887
888        sbom.components.insert(c1.id.clone(), c1);
889        sbom.components.insert(c2.id.clone(), c2);
890
891        let found = sbom.by_purl("pkg:npm/lodash@4.17.21");
892        assert!(found.is_some());
893        assert_eq!(found.unwrap().name, "lodash");
894
895        assert!(sbom.by_purl("pkg:npm/nonexistent@1.0").is_none());
896    }
897
898    #[test]
899    fn test_component_id_unparseable_purl() {
900        // a purl string that can't be parsed should still be used as-is
901        let id = ComponentId::new(Some("not-a-valid-purl-but-still-a-string"), &[]);
902        assert_eq!(id.as_str(), "not-a-valid-purl-but-still-a-string");
903    }
904
905    #[test]
906    fn test_component_id_display() {
907        let id = ComponentId::new(Some("pkg:npm/foo@1.0"), &[]);
908        assert_eq!(format!("{}", id), "pkg:npm/foo@1.0");
909    }
910
911    #[test]
912    fn test_sbom_normalize_clears_metadata() {
913        let mut sbom = Sbom::default();
914        sbom.metadata.timestamp = Some("2024-01-01T00:00:00Z".into());
915        sbom.metadata.tools.push("syft".into());
916        sbom.metadata.authors.push("alice".into());
917
918        let c = Component::new("a".into(), Some("1".into()));
919        sbom.components.insert(c.id.clone(), c);
920
921        sbom.normalize();
922
923        assert!(sbom.metadata.timestamp.is_none());
924        assert!(sbom.metadata.tools.is_empty());
925        assert!(sbom.metadata.authors.is_empty());
926    }
927
928    #[test]
929    fn test_missing_hashes_mixed() {
930        let mut sbom = Sbom::default();
931
932        let c1 = Component::new("no-hash".into(), Some("1.0".into()));
933        let mut c2 = Component::new("has-hash".into(), Some("1.0".into()));
934        c2.hashes.insert("sha256".into(), "abc".into());
935
936        sbom.components.insert(c1.id.clone(), c1);
937        sbom.components.insert(c2.id.clone(), c2);
938
939        let missing = sbom.missing_hashes();
940        assert_eq!(missing.len(), 1);
941    }
942
943    #[test]
944    fn test_ecosystem_from_purl() {
945        use super::ecosystem_from_purl;
946
947        assert_eq!(
948            ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
949            Some("npm".to_string())
950        );
951        assert_eq!(
952            ecosystem_from_purl("pkg:cargo/serde@1.0.0"),
953            Some("cargo".to_string())
954        );
955        assert_eq!(
956            ecosystem_from_purl("pkg:pypi/requests@2.28.0"),
957            Some("pypi".to_string())
958        );
959        assert_eq!(
960            ecosystem_from_purl("pkg:maven/org.apache/commons@1.0"),
961            Some("maven".to_string())
962        );
963        assert_eq!(ecosystem_from_purl("invalid-purl"), None);
964        assert_eq!(ecosystem_from_purl(""), None);
965    }
966
967    #[test]
968    fn test_canonical_algorithm_name() {
969        // SHA family without hyphens (SPDX style)
970        assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
971        assert_eq!(canonical_algorithm_name("SHA1"), "SHA-1");
972        assert_eq!(canonical_algorithm_name("SHA384"), "SHA-384");
973        assert_eq!(canonical_algorithm_name("SHA512"), "SHA-512");
974        assert_eq!(canonical_algorithm_name("SHA224"), "SHA-224");
975
976        // SHA family with hyphens (CycloneDX style)
977        assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
978        assert_eq!(canonical_algorithm_name("SHA-1"), "SHA-1");
979        assert_eq!(canonical_algorithm_name("SHA-384"), "SHA-384");
980
981        // case-insensitive
982        assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
983        assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
984
985        // SHA-3
986        assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
987        assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
988
989        // MD family
990        assert_eq!(canonical_algorithm_name("MD5"), "MD5");
991        assert_eq!(canonical_algorithm_name("md5"), "MD5");
992
993        // BLAKE
994        assert_eq!(canonical_algorithm_name("BLAKE2b-256"), "BLAKE2b-256");
995        assert_eq!(canonical_algorithm_name("BLAKE2B256"), "BLAKE2b-256");
996        assert_eq!(canonical_algorithm_name("BLAKE3"), "BLAKE3");
997
998        // ADLER
999        assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
1000        assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
1001
1002        // unknown algorithm passes through
1003        assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
1004    }
1005
1006    #[test]
1007    fn test_hash_algorithm_strength_ordering() {
1008        // ordering: MD5 < SHA-1 < SHA-224 < SHA-256 < SHA-384 < SHA-512
1009        let md5 = hash_algorithm_strength("MD5").unwrap();
1010        let sha1 = hash_algorithm_strength("SHA-1").unwrap();
1011        let sha224 = hash_algorithm_strength("SHA-224").unwrap();
1012        let sha256 = hash_algorithm_strength("SHA-256").unwrap();
1013        let sha384 = hash_algorithm_strength("SHA-384").unwrap();
1014        let sha512 = hash_algorithm_strength("SHA-512").unwrap();
1015
1016        assert!(md5 < sha1);
1017        assert!(sha1 < sha224);
1018        assert!(sha224 < sha256);
1019        assert!(sha256 < sha384);
1020        assert!(sha384 < sha512);
1021    }
1022
1023    #[test]
1024    fn test_hash_algorithm_strength_variants() {
1025        // case and hyphenation variants resolve to same strength
1026        assert_eq!(
1027            hash_algorithm_strength("sha256"),
1028            hash_algorithm_strength("SHA-256")
1029        );
1030        assert_eq!(
1031            hash_algorithm_strength("sha-1"),
1032            hash_algorithm_strength("SHA1")
1033        );
1034
1035        // SHA-3 at same tier as SHA-2 equivalent
1036        assert_eq!(
1037            hash_algorithm_strength("SHA3-256"),
1038            hash_algorithm_strength("SHA-256")
1039        );
1040        assert_eq!(
1041            hash_algorithm_strength("SHA3-512"),
1042            hash_algorithm_strength("SHA-512")
1043        );
1044
1045        // BLAKE at same tier as SHA-2 equivalent
1046        assert_eq!(
1047            hash_algorithm_strength("BLAKE2b-256"),
1048            hash_algorithm_strength("SHA-256")
1049        );
1050        assert_eq!(
1051            hash_algorithm_strength("BLAKE3"),
1052            hash_algorithm_strength("SHA-256")
1053        );
1054
1055        // unknown returns None
1056        assert_eq!(hash_algorithm_strength("TIGER"), None);
1057        assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
1058    }
1059
1060    #[test]
1061    fn test_hash_algorithm_strength_adler() {
1062        let adler = hash_algorithm_strength("ADLER-32").unwrap();
1063        let md5 = hash_algorithm_strength("MD5").unwrap();
1064        assert!(adler < md5);
1065    }
1066
1067    #[test]
1068    fn test_is_hash_algorithm_downgrade_sha256_to_md5() {
1069        let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1070        let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1071        assert!(is_hash_algorithm_downgrade(&old, &new));
1072    }
1073
1074    #[test]
1075    fn test_is_hash_algorithm_downgrade_upgrade_not_flagged() {
1076        let old: BTreeMap<String, String> = [("sha-1".into(), "abc".into())].into();
1077        let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1078        assert!(!is_hash_algorithm_downgrade(&old, &new));
1079    }
1080
1081    #[test]
1082    fn test_is_hash_algorithm_downgrade_same_algorithm() {
1083        let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1084        let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1085        assert!(!is_hash_algorithm_downgrade(&old, &new));
1086    }
1087
1088    #[test]
1089    fn test_is_hash_algorithm_downgrade_empty_old() {
1090        let old: BTreeMap<String, String> = BTreeMap::new();
1091        let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1092        assert!(!is_hash_algorithm_downgrade(&old, &new));
1093    }
1094
1095    #[test]
1096    fn test_is_hash_algorithm_downgrade_empty_new() {
1097        let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1098        let new: BTreeMap<String, String> = BTreeMap::new();
1099        assert!(!is_hash_algorithm_downgrade(&old, &new));
1100    }
1101
1102    #[test]
1103    fn test_is_hash_algorithm_downgrade_multi_algorithm() {
1104        // old has SHA-256 + MD5, new has only MD5 → downgrade (strongest dropped)
1105        let old: BTreeMap<String, String> = [
1106            ("sha-256".into(), "abc".into()),
1107            ("md5".into(), "xyz".into()),
1108        ]
1109        .into();
1110        let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1111        assert!(is_hash_algorithm_downgrade(&old, &new));
1112    }
1113
1114    #[test]
1115    fn test_is_hash_algorithm_downgrade_multi_algorithm_kept() {
1116        // old has SHA-256 + MD5, new has SHA-256 + SHA-1 → not a downgrade
1117        let old: BTreeMap<String, String> = [
1118            ("sha-256".into(), "abc".into()),
1119            ("md5".into(), "xyz".into()),
1120        ]
1121        .into();
1122        let new: BTreeMap<String, String> = [
1123            ("sha-256".into(), "def".into()),
1124            ("sha-1".into(), "ghi".into()),
1125        ]
1126        .into();
1127        assert!(!is_hash_algorithm_downgrade(&old, &new));
1128    }
1129
1130    #[test]
1131    fn test_detect_cycles_none() {
1132        let mut sbom = Sbom::default();
1133        let c1 = Component::new("a".into(), Some("1".into()));
1134        let c2 = Component::new("b".into(), Some("1".into()));
1135        let c3 = Component::new("c".into(), Some("1".into()));
1136
1137        let id1 = c1.id.clone();
1138        let id2 = c2.id.clone();
1139        let id3 = c3.id.clone();
1140
1141        sbom.components.insert(id1.clone(), c1);
1142        sbom.components.insert(id2.clone(), c2);
1143        sbom.components.insert(id3.clone(), c3);
1144
1145        // a -> b -> c (no cycle)
1146        sbom.dependencies
1147            .entry(id1.clone())
1148            .or_default()
1149            .insert(id2.clone(), DependencyKind::Runtime);
1150        sbom.dependencies
1151            .entry(id2.clone())
1152            .or_default()
1153            .insert(id3.clone(), DependencyKind::Runtime);
1154
1155        assert!(sbom.detect_cycles().is_empty());
1156    }
1157
1158    #[test]
1159    fn test_detect_cycles_simple() {
1160        let mut sbom = Sbom::default();
1161        let c1 = Component::new("a".into(), Some("1".into()));
1162        let c2 = Component::new("b".into(), Some("1".into()));
1163
1164        let id1 = c1.id.clone();
1165        let id2 = c2.id.clone();
1166
1167        sbom.components.insert(id1.clone(), c1);
1168        sbom.components.insert(id2.clone(), c2);
1169
1170        // a -> b -> a (cycle)
1171        sbom.dependencies
1172            .entry(id1.clone())
1173            .or_default()
1174            .insert(id2.clone(), DependencyKind::Runtime);
1175        sbom.dependencies
1176            .entry(id2.clone())
1177            .or_default()
1178            .insert(id1.clone(), DependencyKind::Runtime);
1179
1180        let cycles = sbom.detect_cycles();
1181        assert_eq!(cycles.len(), 1);
1182        // cycle should start and end with the same node
1183        assert_eq!(cycles[0].first(), cycles[0].last());
1184    }
1185
1186    #[test]
1187    fn test_detect_cycles_self_loop() {
1188        let mut sbom = Sbom::default();
1189        let c1 = Component::new("a".into(), Some("1".into()));
1190        let id1 = c1.id.clone();
1191        sbom.components.insert(id1.clone(), c1);
1192
1193        // a -> a (self-loop)
1194        sbom.dependencies
1195            .entry(id1.clone())
1196            .or_default()
1197            .insert(id1.clone(), DependencyKind::Runtime);
1198
1199        let cycles = sbom.detect_cycles();
1200        assert_eq!(cycles.len(), 1);
1201        assert_eq!(cycles[0].len(), 2); // [a, a]
1202    }
1203
1204    #[test]
1205    fn test_detect_cycles_empty_graph() {
1206        let sbom = Sbom::default();
1207        assert!(sbom.detect_cycles().is_empty());
1208    }
1209
1210    #[test]
1211    fn test_detect_cycles_three_node() {
1212        let mut sbom = Sbom::default();
1213        let c1 = Component::new("a".into(), Some("1".into()));
1214        let c2 = Component::new("b".into(), Some("1".into()));
1215        let c3 = Component::new("c".into(), Some("1".into()));
1216
1217        let id1 = c1.id.clone();
1218        let id2 = c2.id.clone();
1219        let id3 = c3.id.clone();
1220
1221        sbom.components.insert(id1.clone(), c1);
1222        sbom.components.insert(id2.clone(), c2);
1223        sbom.components.insert(id3.clone(), c3);
1224
1225        // a -> b -> c -> a (three-node cycle)
1226        sbom.dependencies
1227            .entry(id1.clone())
1228            .or_default()
1229            .insert(id2.clone(), DependencyKind::Runtime);
1230        sbom.dependencies
1231            .entry(id2.clone())
1232            .or_default()
1233            .insert(id3.clone(), DependencyKind::Runtime);
1234        sbom.dependencies
1235            .entry(id3.clone())
1236            .or_default()
1237            .insert(id1.clone(), DependencyKind::Runtime);
1238
1239        let cycles = sbom.detect_cycles();
1240        assert_eq!(cycles.len(), 1);
1241        assert_eq!(cycles[0].first(), cycles[0].last());
1242        assert_eq!(cycles[0].len(), 4); // [a, b, c, a]
1243    }
1244
1245    #[test]
1246    fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
1247        // both have only unknown algorithms → false (can't determine ordering)
1248        let old: BTreeMap<String, String> = [("TIGER".into(), "abc".into())].into();
1249        let new: BTreeMap<String, String> = [("WHIRLPOOL".into(), "def".into())].into();
1250        assert!(!is_hash_algorithm_downgrade(&old, &new));
1251    }
1252
1253    #[test]
1254    fn test_is_copyleft_license() {
1255        // GPL family and its relatives are copyleft
1256        assert!(is_copyleft_license("GPL-3.0-only"));
1257        assert!(is_copyleft_license("AGPL-3.0-only"));
1258        assert!(is_copyleft_license("LGPL-3.0-only"));
1259        // permissive licenses are not
1260        assert!(!is_copyleft_license("MIT"));
1261        assert!(!is_copyleft_license("Apache-2.0"));
1262        assert!(!is_copyleft_license("BSD-3-Clause"));
1263        // LicenseRef and unrecognized ids are conservatively not copyleft
1264        assert!(!is_copyleft_license("LicenseRef-proprietary"));
1265        assert!(!is_copyleft_license("NOT-A-LICENSE"));
1266    }
1267
1268    #[test]
1269    fn test_copyleft_introduced_permissive_to_copyleft() {
1270        let old: BTreeSet<String> = ["MIT".into()].into();
1271        let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1272        assert!(copyleft_introduced(&old, &new));
1273    }
1274
1275    #[test]
1276    fn test_copyleft_introduced_permissive_to_permissive() {
1277        let old: BTreeSet<String> = ["MIT".into()].into();
1278        let new: BTreeSet<String> = ["Apache-2.0".into()].into();
1279        assert!(!copyleft_introduced(&old, &new));
1280    }
1281
1282    #[test]
1283    fn test_copyleft_introduced_carried_over_not_flagged() {
1284        // a copyleft license already present in old is not a new introduction
1285        let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1286        let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1287        assert!(!copyleft_introduced(&old, &new));
1288    }
1289
1290    #[test]
1291    fn test_copyleft_introduced_added_alongside_existing() {
1292        // a second, newly added copyleft id fires even when old already had one
1293        let old: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1294        let new: BTreeSet<String> = ["GPL-3.0-only".into(), "AGPL-3.0-only".into()].into();
1295        assert!(copyleft_introduced(&old, &new));
1296    }
1297}