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    /// the SPDX license expression the source document declared, when it
209    /// declared one (e.g., "MIT OR Apache-2.0").
210    ///
211    /// [`licenses`](Self::licenses) is the flattened identifier set of this
212    /// expression and stays populated either way; policy decisions should go
213    /// through [`licensing`](Self::licensing), which keeps the operators.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub license_expression: Option<String>,
216    /// checksums keyed by algorithm (e.g., "sha256" -> "abc123...").
217    pub hashes: BTreeMap<String, String>,
218    /// original identifiers from the source document (e.g., SPDX SPDXRef, CycloneDX bom-ref).
219    pub source_ids: Vec<String>,
220}
221
222impl Component {
223    /// creates a new component with the given name and optional version.
224    ///
225    /// the component ID is generated from a hash of the name and version.
226    /// use this for simple cases; for full control, construct the struct directly.
227    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    /// returns the licensing this component declares, for policy evaluation.
250    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    /// normalizes the SBOM for deterministic comparison.
260    ///
261    /// this method:
262    /// - sorts components by ID
263    /// - deduplicates and sorts licenses within each component
264    /// - lowercases hash algorithms and values
265    /// - clears volatile metadata (timestamps, tools, authors)
266    ///
267    /// call this before comparing two SBOMs to ignore irrelevant differences.
268    pub fn normalize(&mut self) {
269        // sort components by ID for deterministic output
270        self.components.sort_keys();
271
272        // normalize components
273        for component in self.components.values_mut() {
274            component.normalize();
275        }
276
277        // strip volatile metadata
278        self.metadata.timestamp = None;
279        self.metadata.tools.clear();
280        self.metadata.authors.clear();
281
282        self.rebuild_reverse_deps();
283    }
284
285    /// rebuilds the reverse dependency index from the forward `dependencies` map.
286    ///
287    /// must be called after modifying `dependencies` for `rdeps()` and `roots()`
288    /// to return correct results. Parsers call this automatically; call it
289    /// explicitly when constructing an `Sbom` by hand.
290    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    /// returns root components (those not depended on by any other component).
303    ///
304    /// these are typically the top-level packages or applications in the SBOM.
305    /// uses the precomputed `reverse_deps` index.
306    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    /// returns direct dependencies of the given component.
315    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    /// returns reverse dependencies (components that depend on the given component).
323    /// uses the precomputed `reverse_deps` index.
324    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    /// returns all transitive dependencies of the given component.
332    ///
333    /// traverses the dependency graph depth-first and returns all reachable components.
334    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(&current) {
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    /// returns all unique ecosystems present in the SBOM.
350    pub fn ecosystems(&self) -> BTreeSet<String> {
351        self.components
352            .values()
353            .filter_map(|c| c.ecosystem.clone())
354            .collect()
355    }
356
357    /// returns all unique licenses present across all components.
358    pub fn licenses(&self) -> BTreeSet<String> {
359        self.components
360            .values()
361            .flat_map(|c| c.licenses.iter().cloned())
362            .collect()
363    }
364
365    /// returns components that have no checksums/hashes.
366    ///
367    /// useful for identifying components that may need integrity verification.
368    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    /// finds a component by its package URL.
377    pub fn by_purl(&self, purl: &str) -> Option<&Component> {
378        let id = ComponentId::new(Some(purl), &[]);
379        self.components.get(&id)
380    }
381
382    /// detects dependency cycles in the SBOM's dependency graph.
383    ///
384    /// uses iterative stack-based depth-first search with three-color marking
385    /// (white/gray/black) to find all distinct cycles. each returned vector
386    /// contains the component IDs forming a cycle, starting and ending with
387    /// the same ID.
388    ///
389    /// returns an empty vector if the graph is acyclic.
390    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    /// normalizes the component for deterministic comparison.
446    ///
447    /// lowercases hash keys and values. licenses are stored as a BTreeSet
448    /// so they're already sorted and deduplicated.
449    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
459/// extracts the ecosystem (package type) from a purl string.
460///
461/// returns `None` if the purl is invalid or cannot be parsed.
462///
463/// # Example
464///
465/// ```
466/// use sbom_model::ecosystem_from_purl;
467///
468/// assert_eq!(ecosystem_from_purl("pkg:npm/lodash@4.17.21"), Some("npm".to_string()));
469/// assert_eq!(ecosystem_from_purl("pkg:cargo/serde@1.0.0"), Some("cargo".to_string()));
470/// assert_eq!(ecosystem_from_purl("invalid"), None);
471/// ```
472pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
473    PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
474}
475
476/// extracts individual license IDs from an SPDX expression.
477///
478/// parses the expression and returns all license IDs found, including
479/// `LicenseRef-` identifiers. if parsing fails, returns the original
480/// string as a single-element set.
481///
482/// # Example
483///
484/// ```
485/// use sbom_model::parse_license_expression;
486///
487/// let ids = parse_license_expression("MIT OR Apache-2.0");
488/// assert!(ids.contains("MIT"));
489/// assert!(ids.contains("Apache-2.0"));
490///
491/// let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
492/// assert!(ids.contains("LicenseRef-proprietary"));
493/// assert!(ids.contains("Apache-2.0"));
494/// ```
495pub 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                // expression parsed but no IDs found, keep original
507                BTreeSet::from([license.to_string()])
508            } else {
509                ids
510            }
511        }
512        Err(_) => {
513            // not a valid SPDX expression, keep original
514            BTreeSet::from([license.to_string()])
515        }
516    }
517}
518
519/// a single license requirement: one identifier plus the `WITH` exception
520/// bound to it, if any.
521#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
522pub struct LicenseRequirement {
523    /// the license identifier, e.g. `GPL-2.0-only` or `LicenseRef-proprietary`.
524    pub license: String,
525    /// whether the identifier carried a `+` suffix.
526    pub or_later: bool,
527    /// the exception identifier following `WITH`.
528    pub exception: Option<String>,
529}
530
531impl LicenseRequirement {
532    /// builds a bare requirement from a license identifier.
533    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/// the licensing a component declares.
568///
569/// carries the SPDX expression when the source document had one, so `AND`/`OR`
570/// and `WITH` survive; otherwise it falls back to the flattened identifier set,
571/// which is read as a conjunction (every identifier applies).
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573pub struct Licensing<'a> {
574    /// the declared SPDX expression, if any.
575    pub expression: Option<&'a str>,
576    /// the flattened license identifiers.
577    pub ids: &'a BTreeSet<String>,
578}
579
580impl<'a> Licensing<'a> {
581    /// builds a licensing view from a flat identifier set.
582    pub fn from_ids(ids: &'a BTreeSet<String>) -> Self {
583        Self {
584            expression: None,
585            ids,
586        }
587    }
588
589    /// reports whether the licensing can be satisfied by taking on only
590    /// requirements `acceptable` approves of.
591    ///
592    /// `OR` lets the consumer pick one side, `AND` demands both. an expression
593    /// that fails to parse, or an absent expression, falls back to requiring
594    /// every identifier in the flat set.
595    ///
596    /// # Example
597    ///
598    /// ```
599    /// use sbom_model::Licensing;
600    /// use std::collections::BTreeSet;
601    ///
602    /// let ids: BTreeSet<String> = ["Apache-2.0".into(), "MIT".into()].into();
603    /// let choice = Licensing { expression: Some("MIT OR Apache-2.0"), ids: &ids };
604    /// assert!(choice.satisfiable(|r| r.license == "MIT"));
605    ///
606    /// let both = Licensing { expression: Some("MIT AND Apache-2.0"), ids: &ids };
607    /// assert!(!both.satisfiable(|r| r.license == "MIT"));
608    /// ```
609    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    /// returns every requirement the licensing mentions, whether or not a
624    /// consumer must accept it.
625    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    /// returns the minimal requirement sets that satisfy the licensing, or
641    /// `None` when the expression expands past `MAX_CHOICES`.
642    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    /// returns the copyleft identifiers no satisfying choice can avoid.
656    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    /// returns the minimal copyleft burdens the licensing's satisfying choices
666    /// carry, or `None` when the expression expands past `MAX_CHOICES`.
667    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
683/// the alternative requirement sets an expression offers a consumer.
684type Choices = BTreeSet<BTreeSet<LicenseRequirement>>;
685
686/// the copyleft identifiers each alternative an expression offers carries.
687type Burdens = BTreeSet<BTreeSet<String>>;
688
689/// the largest number of alternatives an expression is expanded into before it
690/// is compared by spelling instead.
691const MAX_CHOICES: usize = 64;
692
693/// drops every set that another set in the collection is a strict subset of.
694fn 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
704/// expands a parsed expression into its minimal satisfying requirement sets.
705fn 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
735/// reports whether two licensings impose the same obligations.
736///
737/// a licensing without a declared expression is read as a conjunction of its
738/// identifiers, so it differs from one with an expression only when that
739/// expression means something a bare identifier set cannot. an expression too
740/// wide to expand is compared by spelling and never matches a bare set.
741///
742/// # Example
743///
744/// ```
745/// use sbom_model::{licensings_equivalent, Licensing};
746/// use std::collections::BTreeSet;
747///
748/// let ids: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
749/// let flat = Licensing::from_ids(&ids);
750///
751/// let both = Licensing { expression: Some("MIT AND GPL-3.0-only"), ids: &ids };
752/// assert!(licensings_equivalent(both, flat));
753///
754/// let choice = Licensing { expression: Some("MIT OR GPL-3.0-only"), ids: &ids };
755/// assert!(!licensings_equivalent(choice, flat));
756/// ```
757pub 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
767/// returns the copyleft licenses `new` can put a consumer under that `old`
768/// could not.
769///
770/// empty when `new` offers a way to satisfy it whose copyleft obligations `old`
771/// already offered — so a dual license like `MIT OR GPL-3.0-only` is not an
772/// introduction, while `MIT AND GPL-3.0-only` is, and an unchanged
773/// `GPL-2.0-only OR GPL-3.0-only` choice is neither. otherwise it is the
774/// copyleft carried by `new`'s minimal satisfying choices, minus the copyleft
775/// every choice of `old` already carried; those choices need not be comparable,
776/// so a consumer may end up under only some of the returned licenses. an
777/// expression too wide to expand is measured against the copyleft `old` made
778/// individually unavoidable.
779///
780/// # Example
781///
782/// ```
783/// use sbom_model::{copyleft_obligations_added, Licensing};
784/// use std::collections::BTreeSet;
785///
786/// let old_ids: BTreeSet<String> = ["MIT".into()].into();
787/// let new_ids: BTreeSet<String> = ["GPL-3.0-only".into(), "MIT".into()].into();
788/// let old = Licensing { expression: Some("MIT"), ids: &old_ids };
789///
790/// let choice = Licensing { expression: Some("MIT OR GPL-3.0-only"), ids: &new_ids };
791/// assert!(copyleft_obligations_added(old, choice).is_empty());
792///
793/// let both = Licensing { expression: Some("MIT AND GPL-3.0-only"), ids: &new_ids };
794/// assert!(copyleft_obligations_added(old, both).contains("GPL-3.0-only"));
795/// ```
796pub 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
830/// reports whether two SPDX license expressions impose the same obligations.
831///
832/// spelling that does not change which licenses satisfy the expression —
833/// redundant parentheses, whitespace, operand order — is not a difference;
834/// `AND` against `OR`, `WITH` exceptions and `+` are. an expression too wide to
835/// expand compares by parse tree, and one that fails to parse as a plain string.
836///
837/// # Example
838///
839/// ```
840/// use sbom_model::license_expressions_equivalent;
841///
842/// assert!(license_expressions_equivalent("MIT OR Apache-2.0", "(Apache-2.0 OR MIT)"));
843/// assert!(!license_expressions_equivalent("MIT OR Apache-2.0", "MIT AND Apache-2.0"));
844/// assert!(!license_expressions_equivalent(
845///     "GPL-2.0-only",
846///     "GPL-2.0-only WITH Classpath-exception-2.0",
847/// ));
848/// ```
849pub 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
859/// normalizes a hash algorithm name to its canonical form.
860///
861/// handles variations in casing and hyphenation so that algorithm names
862/// from different SBOM formats (SPDX, CycloneDX) compare equal.
863///
864/// # Example
865///
866/// ```
867/// use sbom_model::canonical_algorithm_name;
868///
869/// assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
870/// assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
871/// assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
872/// ```
873pub 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
897/// returns the strength tier of a hash algorithm, where higher values
898/// indicate stronger algorithms.
899///
900/// returns `None` for unrecognized algorithms. The tiers are:
901/// - 0: Non-cryptographic checksums (ADLER-32)
902/// - 1: Broken cryptographic hashes (MD2, MD4, MD5)
903/// - 2: Weak cryptographic hashes (SHA-1)
904/// - 3: 112-bit security (SHA-224)
905/// - 4: 128-bit security (SHA-256, SHA3-256, BLAKE2b-256, BLAKE3, MD6)
906/// - 5: 192-bit security (SHA-384, SHA3-384, BLAKE2b-384)
907/// - 6: 256-bit security (SHA-512, SHA3-512, BLAKE2b-512)
908///
909/// # Example
910///
911/// ```
912/// use sbom_model::hash_algorithm_strength;
913///
914/// assert!(hash_algorithm_strength("SHA-256").unwrap() > hash_algorithm_strength("MD5").unwrap());
915/// assert!(hash_algorithm_strength("SHA-512").unwrap() > hash_algorithm_strength("SHA-256").unwrap());
916/// assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
917/// ```
918pub 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
932/// detects whether the hash algorithms in a component were downgraded.
933///
934/// compares the strongest known algorithm in `old_hashes` against the
935/// strongest known algorithm in `new_hashes`. Returns `true` if the new
936/// set's strongest algorithm is weaker than the old set's strongest.
937///
938/// returns `false` when:
939/// - either hash set is empty (use `missing-hashes` for that)
940/// - neither set contains a recognized algorithm
941/// - the new set is at least as strong as the old set
942///
943/// # Example
944///
945/// ```
946/// use sbom_model::is_hash_algorithm_downgrade;
947/// use std::collections::BTreeMap;
948///
949/// let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
950/// let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
951/// assert!(is_hash_algorithm_downgrade(&old, &new));
952///
953/// let new_strong: BTreeMap<String, String> = [("sha-512".into(), "ghi".into())].into();
954/// assert!(!is_hash_algorithm_downgrade(&old, &new_strong));
955/// ```
956pub 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
979/// classifies an SPDX license identifier as copyleft.
980///
981/// looks the ID up in the compile-time SPDX license list and returns whether
982/// it carries the copyleft flag (the GPL/AGPL/LGPL family, MPL, etc.).
983///
984/// returns `false` for anything SPDX doesn't recognize — `LicenseRef-`
985/// identifiers, full license expressions, and free-text names — a conservative
986/// default so unknown terms never trip a copyleft gate.
987///
988/// # Example
989///
990/// ```
991/// use sbom_model::is_copyleft_license;
992///
993/// assert!(is_copyleft_license("GPL-3.0-only"));
994/// assert!(is_copyleft_license("AGPL-3.0-only"));
995/// assert!(!is_copyleft_license("MIT"));
996/// assert!(!is_copyleft_license("LicenseRef-proprietary"));
997/// ```
998pub fn is_copyleft_license(id: &str) -> bool {
999    spdx::license_id(id)
1000        .map(|l| l.is_copyleft())
1001        .unwrap_or(false)
1002}
1003
1004/// detects whether a copyleft license was newly introduced between two license sets.
1005///
1006/// returns `true` iff `new` contains a copyleft license (per
1007/// [`is_copyleft_license`]) that is not present in `old`. A copyleft license
1008/// carried over from `old` is not an introduction, and permissive-only changes
1009/// never fire.
1010///
1011/// # Example
1012///
1013/// ```
1014/// use sbom_model::copyleft_introduced;
1015/// use std::collections::BTreeSet;
1016///
1017/// let old: BTreeSet<String> = ["MIT".into()].into();
1018/// let new: BTreeSet<String> = ["GPL-3.0-only".into()].into();
1019/// assert!(copyleft_introduced(&old, &new));
1020///
1021/// let permissive: BTreeSet<String> = ["Apache-2.0".into()].into();
1022/// assert!(!copyleft_introduced(&old, &permissive));
1023/// ```
1024pub 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        // a different copyleft license is still a new obligation
1157        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        // OR expression extracts both IDs
1446        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        // single license
1452        let ids = parse_license_expression("MIT");
1453        assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
1454
1455        // AND expression extracts both IDs
1456        let ids = parse_license_expression("MIT AND Apache-2.0");
1457        assert!(ids.contains("MIT"));
1458        assert!(ids.contains("Apache-2.0"));
1459
1460        // invalid expression kept as-is
1461        let ids = parse_license_expression("Custom License");
1462        assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
1463
1464        // pure LicenseRef
1465        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        // mixed LicenseRef + SPDX-ID with AND: both must be extracted
1472        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        // mixed LicenseRef + SPDX-ID with OR
1481        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        // multiple LicenseRef terms
1490        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        // complex expression mixing LicenseRef and standard IDs
1499        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        // DocumentRef-prefixed LicenseRef
1509        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        // two components with same licenses in different order are equal
1519        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        // id1 -> id2 -> id3
1546        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        // a purl string that can't be parsed should still be used as-is
1636        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        // SHA family without hyphens (SPDX style)
1705        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        // SHA family with hyphens (CycloneDX style)
1712        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        // case-insensitive
1717        assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
1718        assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
1719
1720        // SHA-3
1721        assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
1722        assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
1723
1724        // MD family
1725        assert_eq!(canonical_algorithm_name("MD5"), "MD5");
1726        assert_eq!(canonical_algorithm_name("md5"), "MD5");
1727
1728        // BLAKE
1729        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        // ADLER
1734        assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
1735        assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
1736
1737        // unknown algorithm passes through
1738        assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
1739    }
1740
1741    #[test]
1742    fn test_hash_algorithm_strength_ordering() {
1743        // ordering: MD5 < SHA-1 < SHA-224 < SHA-256 < SHA-384 < SHA-512
1744        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        // case and hyphenation variants resolve to same strength
1761        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        // SHA-3 at same tier as SHA-2 equivalent
1771        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        // BLAKE at same tier as SHA-2 equivalent
1781        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        // unknown returns None
1791        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        // old has SHA-256 + MD5, new has only MD5 → downgrade (strongest dropped)
1840        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        // old has SHA-256 + MD5, new has SHA-256 + SHA-1 → not a downgrade
1852        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        // a -> b -> c (no cycle)
1881        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        // a -> b -> a (cycle)
1906        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        // cycle should start and end with the same node
1918        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        // a -> a (self-loop)
1929        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); // [a, a]
1937    }
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        // a -> b -> c -> a (three-node cycle)
1961        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); // [a, b, c, a]
1978    }
1979
1980    #[test]
1981    fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
1982        // both have only unknown algorithms → false (can't determine ordering)
1983        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        // GPL family and its relatives are copyleft
1991        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        // permissive licenses are not
1995        assert!(!is_copyleft_license("MIT"));
1996        assert!(!is_copyleft_license("Apache-2.0"));
1997        assert!(!is_copyleft_license("BSD-3-Clause"));
1998        // LicenseRef and unrecognized ids are conservatively not copyleft
1999        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        // a copyleft license already present in old is not a new introduction
2020        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        // a second, newly added copyleft id fires even when old already had one
2028        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}