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            // try to canonicalize purl
156            if let Ok(parsed) = PackageUrl::from_str(purl) {
157                return ComponentId(parsed.to_string());
158            }
159            return ComponentId(purl.to_string());
160        }
161
162        // deterministic hash fallback
163        let mut hasher = Sha256::new();
164        for (k, v) in properties {
165            hasher.update(k.as_bytes());
166            hasher.update(b":");
167            hasher.update(v.as_bytes());
168            hasher.update(b"|");
169        }
170        let hash = hex::encode(hasher.finalize());
171        ComponentId(format!("h:{}", hash))
172    }
173
174    /// returns the identifier as a string slice.
175    pub fn as_str(&self) -> &str {
176        &self.0
177    }
178}
179
180impl std::fmt::Display for ComponentId {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(f, "{}", self.0)
183    }
184}
185
186/// a software component (package, library, or application).
187///
188/// represents a single entry in the SBOM with all its metadata.
189/// components are identified by their [`ComponentId`] and can have
190/// relationships to other components via the dependency graph.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Component {
193    /// stable identifier for this component.
194    pub id: ComponentId,
195    /// package name (e.g., "serde", "lodash").
196    pub name: String,
197    /// package version (e.g., "1.0.0", "4.17.21").
198    pub version: Option<String>,
199    /// package ecosystem (e.g., "cargo", "npm", "pypi").
200    pub ecosystem: Option<String>,
201    /// package supplier or publisher.
202    pub supplier: Option<String>,
203    /// human-readable description.
204    pub description: Option<String>,
205    /// package URL per the [purl spec](https://github.com/package-url/purl-spec).
206    pub purl: Option<String>,
207    /// SPDX license identifiers (e.g., "MIT", "Apache-2.0").
208    pub licenses: BTreeSet<String>,
209    /// checksums keyed by algorithm (e.g., "sha256" -> "abc123...").
210    pub hashes: BTreeMap<String, String>,
211    /// original identifiers from the source document (e.g., SPDX SPDXRef, CycloneDX bom-ref).
212    pub source_ids: Vec<String>,
213}
214
215impl Component {
216    /// creates a new component with the given name and optional version.
217    ///
218    /// the component ID is generated from a hash of the name and version.
219    /// use this for simple cases; for full control, construct the struct directly.
220    pub fn new(name: String, version: Option<String>) -> Self {
221        let mut props = vec![("name", name.as_str())];
222        if let Some(v) = &version {
223            props.push(("version", v));
224        }
225        let id = ComponentId::new(None, &props);
226
227        Self {
228            id,
229            name,
230            version,
231            ecosystem: None,
232            supplier: None,
233            description: None,
234            purl: None,
235            licenses: BTreeSet::new(),
236            hashes: BTreeMap::new(),
237            source_ids: Vec::new(),
238        }
239    }
240}
241
242impl Sbom {
243    /// normalizes the SBOM for deterministic comparison.
244    ///
245    /// this method:
246    /// - Sorts components by ID
247    /// - Deduplicates and sorts licenses within each component
248    /// - Lowercases hash algorithms and values
249    /// - Clears volatile metadata (timestamps, tools, authors)
250    ///
251    /// call this before comparing two SBOMs to ignore irrelevant differences.
252    pub fn normalize(&mut self) {
253        // sort components by ID for deterministic output
254        self.components.sort_keys();
255
256        // normalize components
257        for component in self.components.values_mut() {
258            component.normalize();
259        }
260
261        // strip volatile metadata
262        self.metadata.timestamp = None;
263        self.metadata.tools.clear();
264        self.metadata.authors.clear(); // Authors might be relevant, but often change slightly. Let's keep strict for now.
265
266        self.rebuild_reverse_deps();
267    }
268
269    /// rebuilds the reverse dependency index from the forward `dependencies` map.
270    ///
271    /// must be called after modifying `dependencies` for `rdeps()` and `roots()`
272    /// to return correct results. Parsers call this automatically; call it
273    /// explicitly when constructing an `Sbom` by hand.
274    pub fn rebuild_reverse_deps(&mut self) {
275        self.reverse_deps.clear();
276        for (parent, children) in &self.dependencies {
277            for child in children.keys() {
278                self.reverse_deps
279                    .entry(child.clone())
280                    .or_default()
281                    .insert(parent.clone());
282            }
283        }
284    }
285
286    /// returns root components (those not depended on by any other component).
287    ///
288    /// these are typically the top-level packages or applications in the SBOM.
289    /// uses the precomputed `reverse_deps` index for O(n) lookup.
290    pub fn roots(&self) -> Vec<ComponentId> {
291        self.components
292            .keys()
293            .filter(|id| self.reverse_deps.get(*id).is_none_or(BTreeSet::is_empty))
294            .cloned()
295            .collect()
296    }
297
298    /// returns direct dependencies of the given component.
299    pub fn deps(&self, id: &ComponentId) -> Vec<ComponentId> {
300        self.dependencies
301            .get(id)
302            .map(|d| d.keys().cloned().collect())
303            .unwrap_or_default()
304    }
305
306    /// returns reverse dependencies (components that depend on the given component).
307    /// uses the precomputed `reverse_deps` index for O(1) lookup.
308    pub fn rdeps(&self, id: &ComponentId) -> Vec<ComponentId> {
309        self.reverse_deps
310            .get(id)
311            .map(|parents| parents.iter().cloned().collect())
312            .unwrap_or_default()
313    }
314
315    /// returns all transitive dependencies of the given component.
316    ///
317    /// traverses the dependency graph depth-first and returns all reachable components.
318    pub fn transitive_deps(&self, id: &ComponentId) -> BTreeSet<ComponentId> {
319        let mut visited = BTreeSet::new();
320        let mut stack = vec![id.clone()];
321        while let Some(current) = stack.pop() {
322            if let Some(children) = self.dependencies.get(&current) {
323                for child in children.keys() {
324                    if visited.insert(child.clone()) {
325                        stack.push(child.clone());
326                    }
327                }
328            }
329        }
330        visited
331    }
332
333    /// returns all unique ecosystems present in the SBOM.
334    pub fn ecosystems(&self) -> BTreeSet<String> {
335        self.components
336            .values()
337            .filter_map(|c| c.ecosystem.clone())
338            .collect()
339    }
340
341    /// returns all unique licenses present across all components.
342    pub fn licenses(&self) -> BTreeSet<String> {
343        self.components
344            .values()
345            .flat_map(|c| c.licenses.iter().cloned())
346            .collect()
347    }
348
349    /// returns components that have no checksums/hashes.
350    ///
351    /// useful for identifying components that may need integrity verification.
352    pub fn missing_hashes(&self) -> Vec<ComponentId> {
353        self.components
354            .iter()
355            .filter(|(_, c)| c.hashes.is_empty())
356            .map(|(id, _)| id.clone())
357            .collect()
358    }
359
360    /// finds a component by its package URL.
361    pub fn by_purl(&self, purl: &str) -> Option<&Component> {
362        let id = ComponentId::new(Some(purl), &[]);
363        self.components.get(&id)
364    }
365
366    /// detects dependency cycles in the SBOM's dependency graph.
367    ///
368    /// uses iterative stack-based depth-first search with three-color marking
369    /// (white/gray/black) to find all distinct cycles. each returned vector
370    /// contains the component IDs forming a cycle, starting and ending with
371    /// the same ID.
372    ///
373    /// returns an empty vector if the graph is acyclic.
374    pub fn detect_cycles(&self) -> Vec<Vec<ComponentId>> {
375        enum Frame {
376            Enter(ComponentId),
377            Exit(ComponentId),
378        }
379
380        let mut visited = BTreeSet::new();
381        let mut on_stack = BTreeSet::new();
382        let mut path = Vec::new();
383        let mut cycles = Vec::new();
384
385        let mut stack: Vec<Frame> = self
386            .dependencies
387            .keys()
388            .rev()
389            .map(|k| Frame::Enter(k.clone()))
390            .collect();
391
392        while let Some(frame) = stack.pop() {
393            match frame {
394                Frame::Enter(node) => {
395                    if visited.contains(&node) {
396                        continue;
397                    }
398                    visited.insert(node.clone());
399                    on_stack.insert(node.clone());
400                    path.push(node.clone());
401                    stack.push(Frame::Exit(node.clone()));
402
403                    if let Some(children) = self.dependencies.get(&node) {
404                        for child in children.keys().rev() {
405                            if !visited.contains(child) {
406                                stack.push(Frame::Enter(child.clone()));
407                            } else if on_stack.contains(child) {
408                                if let Some(start) = path.iter().position(|n| n == child) {
409                                    let mut cycle: Vec<_> = path[start..].to_vec();
410                                    cycle.push(child.clone());
411                                    cycles.push(cycle);
412                                }
413                            }
414                        }
415                    }
416                }
417                Frame::Exit(node) => {
418                    path.pop();
419                    on_stack.remove(&node);
420                }
421            }
422        }
423
424        cycles
425    }
426}
427
428impl Component {
429    /// normalizes the component for deterministic comparison.
430    ///
431    /// lowercases hash keys and values. Licenses are stored as a BTreeSet
432    /// so they're already sorted and deduplicated.
433    pub fn normalize(&mut self) {
434        let normalized_hashes: BTreeMap<String, String> = self
435            .hashes
436            .iter()
437            .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
438            .collect();
439        self.hashes = normalized_hashes;
440    }
441}
442
443/// extracts the ecosystem (package type) from a purl string.
444///
445/// returns `None` if the purl is invalid or cannot be parsed.
446///
447/// # Example
448///
449/// ```
450/// use sbom_model::ecosystem_from_purl;
451///
452/// assert_eq!(ecosystem_from_purl("pkg:npm/lodash@4.17.21"), Some("npm".to_string()));
453/// assert_eq!(ecosystem_from_purl("pkg:cargo/serde@1.0.0"), Some("cargo".to_string()));
454/// assert_eq!(ecosystem_from_purl("invalid"), None);
455/// ```
456pub fn ecosystem_from_purl(purl: &str) -> Option<String> {
457    PackageUrl::from_str(purl).ok().map(|p| p.ty().to_string())
458}
459
460/// extracts individual license IDs from an SPDX expression.
461///
462/// parses the expression and returns all license IDs found, including
463/// `LicenseRef-` identifiers. if parsing fails, returns the original
464/// string as a single-element set.
465///
466/// # Example
467///
468/// ```
469/// use sbom_model::parse_license_expression;
470///
471/// let ids = parse_license_expression("MIT OR Apache-2.0");
472/// assert!(ids.contains("MIT"));
473/// assert!(ids.contains("Apache-2.0"));
474///
475/// let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
476/// assert!(ids.contains("LicenseRef-proprietary"));
477/// assert!(ids.contains("Apache-2.0"));
478/// ```
479pub fn parse_license_expression(license: &str) -> BTreeSet<String> {
480    match spdx::Expression::parse(license) {
481        Ok(expr) => {
482            let ids: BTreeSet<String> = expr
483                .requirements()
484                .map(|r| match &r.req.license {
485                    spdx::LicenseItem::Spdx { id, .. } => id.name.to_string(),
486                    other => other.to_string(),
487                })
488                .collect();
489            if ids.is_empty() {
490                // expression parsed but no IDs found, keep original
491                BTreeSet::from([license.to_string()])
492            } else {
493                ids
494            }
495        }
496        Err(_) => {
497            // not a valid SPDX expression, keep original
498            BTreeSet::from([license.to_string()])
499        }
500    }
501}
502
503/// normalizes a hash algorithm name to its canonical form.
504///
505/// handles variations in casing and hyphenation so that algorithm names
506/// from different SBOM formats (SPDX, CycloneDX) compare equal.
507///
508/// # Example
509///
510/// ```
511/// use sbom_model::canonical_algorithm_name;
512///
513/// assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
514/// assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
515/// assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
516/// ```
517pub fn canonical_algorithm_name(name: &str) -> String {
518    match name.replace('-', "").to_uppercase().as_str() {
519        "MD2" => "MD2",
520        "MD4" => "MD4",
521        "MD5" => "MD5",
522        "MD6" => "MD6",
523        "SHA1" => "SHA-1",
524        "SHA224" => "SHA-224",
525        "SHA256" => "SHA-256",
526        "SHA384" => "SHA-384",
527        "SHA512" => "SHA-512",
528        "SHA3256" => "SHA3-256",
529        "SHA3384" => "SHA3-384",
530        "SHA3512" => "SHA3-512",
531        "BLAKE2B256" => "BLAKE2b-256",
532        "BLAKE2B384" => "BLAKE2b-384",
533        "BLAKE2B512" => "BLAKE2b-512",
534        "BLAKE3" => "BLAKE3",
535        "ADLER32" => "ADLER-32",
536        _ => return name.to_string(),
537    }
538    .to_string()
539}
540
541/// returns the strength tier of a hash algorithm, where higher values
542/// indicate stronger algorithms.
543///
544/// returns `None` for unrecognized algorithms. The tiers are:
545/// - 0: Non-cryptographic checksums (ADLER-32)
546/// - 1: Broken cryptographic hashes (MD2, MD4, MD5)
547/// - 2: Weak cryptographic hashes (SHA-1)
548/// - 3: 112-bit security (SHA-224)
549/// - 4: 128-bit security (SHA-256, SHA3-256, BLAKE2b-256, BLAKE3, MD6)
550/// - 5: 192-bit security (SHA-384, SHA3-384, BLAKE2b-384)
551/// - 6: 256-bit security (SHA-512, SHA3-512, BLAKE2b-512)
552///
553/// # Example
554///
555/// ```
556/// use sbom_model::hash_algorithm_strength;
557///
558/// assert!(hash_algorithm_strength("SHA-256").unwrap() > hash_algorithm_strength("MD5").unwrap());
559/// assert!(hash_algorithm_strength("SHA-512").unwrap() > hash_algorithm_strength("SHA-256").unwrap());
560/// assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
561/// ```
562pub fn hash_algorithm_strength(name: &str) -> Option<u8> {
563    let canonical = canonical_algorithm_name(name);
564    match canonical.as_str() {
565        "ADLER-32" => Some(0),
566        "MD2" | "MD4" | "MD5" => Some(1),
567        "SHA-1" => Some(2),
568        "SHA-224" => Some(3),
569        "SHA-256" | "SHA3-256" | "BLAKE2b-256" | "BLAKE3" | "MD6" => Some(4),
570        "SHA-384" | "SHA3-384" | "BLAKE2b-384" => Some(5),
571        "SHA-512" | "SHA3-512" | "BLAKE2b-512" => Some(6),
572        _ => None,
573    }
574}
575
576/// detects whether the hash algorithms in a component were downgraded.
577///
578/// compares the strongest known algorithm in `old_hashes` against the
579/// strongest known algorithm in `new_hashes`. Returns `true` if the new
580/// set's strongest algorithm is weaker than the old set's strongest.
581///
582/// returns `false` when:
583/// - Either hash set is empty (use `missing-hashes` for that)
584/// - Neither set contains a recognized algorithm
585/// - The new set is at least as strong as the old set
586///
587/// # Example
588///
589/// ```
590/// use sbom_model::is_hash_algorithm_downgrade;
591/// use std::collections::BTreeMap;
592///
593/// let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
594/// let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
595/// assert!(is_hash_algorithm_downgrade(&old, &new));
596///
597/// let new_strong: BTreeMap<String, String> = [("sha-512".into(), "ghi".into())].into();
598/// assert!(!is_hash_algorithm_downgrade(&old, &new_strong));
599/// ```
600pub fn is_hash_algorithm_downgrade(
601    old_hashes: &BTreeMap<String, String>,
602    new_hashes: &BTreeMap<String, String>,
603) -> bool {
604    if old_hashes.is_empty() || new_hashes.is_empty() {
605        return false;
606    }
607
608    let old_max = old_hashes
609        .keys()
610        .filter_map(|k| hash_algorithm_strength(k))
611        .max();
612    let new_max = new_hashes
613        .keys()
614        .filter_map(|k| hash_algorithm_strength(k))
615        .max();
616
617    match (old_max, new_max) {
618        (Some(old_strength), Some(new_strength)) => new_strength < old_strength,
619        _ => false,
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn test_component_id_purl() {
629        let purl = "pkg:npm/left-pad@1.3.0";
630        let id = ComponentId::new(Some(purl), &[]);
631        assert_eq!(id.as_str(), purl);
632    }
633
634    #[test]
635    fn test_component_id_hash_stability() {
636        let props = [("name", "foo"), ("version", "1.0")];
637        let id1 = ComponentId::new(None, &props);
638        let id2 = ComponentId::new(None, &props);
639        assert_eq!(id1, id2);
640        assert!(id1.as_str().starts_with("h:"));
641    }
642
643    #[test]
644    fn test_normalization() {
645        let mut comp = Component::new("test".to_string(), Some("1.0".to_string()));
646        comp.licenses.insert("MIT".to_string());
647        comp.licenses.insert("Apache-2.0".to_string());
648        comp.hashes.insert("SHA-256".to_string(), "ABC".to_string());
649
650        comp.normalize();
651
652        // BTreeSet is already sorted and deduped
653        assert_eq!(
654            comp.licenses,
655            BTreeSet::from(["Apache-2.0".to_string(), "MIT".to_string()])
656        );
657        assert_eq!(comp.hashes.get("sha-256").unwrap(), "abc");
658    }
659
660    #[test]
661    fn test_parse_license_expression() {
662        // OR expression extracts both IDs
663        let ids = parse_license_expression("MIT OR Apache-2.0");
664        assert!(ids.contains("MIT"));
665        assert!(ids.contains("Apache-2.0"));
666        assert_eq!(ids.len(), 2);
667
668        // single license
669        let ids = parse_license_expression("MIT");
670        assert_eq!(ids, BTreeSet::from(["MIT".to_string()]));
671
672        // AND expression extracts both IDs
673        let ids = parse_license_expression("MIT AND Apache-2.0");
674        assert!(ids.contains("MIT"));
675        assert!(ids.contains("Apache-2.0"));
676
677        // invalid expression kept as-is
678        let ids = parse_license_expression("Custom License");
679        assert_eq!(ids, BTreeSet::from(["Custom License".to_string()]));
680
681        // pure LicenseRef
682        let ids = parse_license_expression("LicenseRef-proprietary");
683        assert_eq!(ids, BTreeSet::from(["LicenseRef-proprietary".to_string()]));
684    }
685
686    #[test]
687    fn test_parse_license_expression_licenseref_and_spdx() {
688        // mixed LicenseRef + SPDX-ID with AND: both must be extracted
689        let ids = parse_license_expression("LicenseRef-proprietary AND Apache-2.0");
690        assert!(ids.contains("LicenseRef-proprietary"));
691        assert!(ids.contains("Apache-2.0"));
692        assert_eq!(ids.len(), 2);
693    }
694
695    #[test]
696    fn test_parse_license_expression_licenseref_or_spdx() {
697        // mixed LicenseRef + SPDX-ID with OR
698        let ids = parse_license_expression("LicenseRef-custom OR MIT");
699        assert!(ids.contains("LicenseRef-custom"));
700        assert!(ids.contains("MIT"));
701        assert_eq!(ids.len(), 2);
702    }
703
704    #[test]
705    fn test_parse_license_expression_multiple_licenserefs() {
706        // multiple LicenseRef terms
707        let ids = parse_license_expression("LicenseRef-a AND LicenseRef-b");
708        assert!(ids.contains("LicenseRef-a"));
709        assert!(ids.contains("LicenseRef-b"));
710        assert_eq!(ids.len(), 2);
711    }
712
713    #[test]
714    fn test_parse_license_expression_complex_mixed() {
715        // complex expression mixing LicenseRef and standard IDs
716        let ids = parse_license_expression("(MIT OR LicenseRef-custom) AND Apache-2.0");
717        assert!(ids.contains("MIT"));
718        assert!(ids.contains("LicenseRef-custom"));
719        assert!(ids.contains("Apache-2.0"));
720        assert_eq!(ids.len(), 3);
721    }
722
723    #[test]
724    fn test_parse_license_expression_documentref() {
725        // DocumentRef-prefixed LicenseRef
726        let ids = parse_license_expression("DocumentRef-ext:LicenseRef-custom");
727        assert_eq!(
728            ids,
729            BTreeSet::from(["DocumentRef-ext:LicenseRef-custom".to_string()])
730        );
731    }
732
733    #[test]
734    fn test_license_set_equality() {
735        // two components with same licenses in different order are equal
736        let mut c1 = Component::new("test".into(), None);
737        c1.licenses.insert("MIT".into());
738        c1.licenses.insert("Apache-2.0".into());
739
740        let mut c2 = Component::new("test".into(), None);
741        c2.licenses.insert("Apache-2.0".into());
742        c2.licenses.insert("MIT".into());
743
744        assert_eq!(c1.licenses, c2.licenses);
745    }
746
747    #[test]
748    fn test_query_api() {
749        let mut sbom = Sbom::default();
750        let c1 = Component::new("a".into(), Some("1".into()));
751        let c2 = Component::new("b".into(), Some("1".into()));
752        let c3 = Component::new("c".into(), Some("1".into()));
753
754        let id1 = c1.id.clone();
755        let id2 = c2.id.clone();
756        let id3 = c3.id.clone();
757
758        sbom.components.insert(id1.clone(), c1);
759        sbom.components.insert(id2.clone(), c2);
760        sbom.components.insert(id3.clone(), c3);
761
762        // id1 -> id2 -> id3
763        sbom.dependencies
764            .entry(id1.clone())
765            .or_default()
766            .insert(id2.clone(), DependencyKind::Runtime);
767        sbom.dependencies
768            .entry(id2.clone())
769            .or_default()
770            .insert(id3.clone(), DependencyKind::Runtime);
771        sbom.rebuild_reverse_deps();
772
773        assert_eq!(sbom.roots(), vec![id1.clone()]);
774        assert_eq!(sbom.deps(&id1), vec![id2.clone()]);
775        assert_eq!(sbom.rdeps(&id2), vec![id1.clone()]);
776
777        let transitive = sbom.transitive_deps(&id1);
778        assert!(transitive.contains(&id2));
779        assert!(transitive.contains(&id3));
780        assert_eq!(transitive.len(), 2);
781
782        assert_eq!(sbom.missing_hashes().len(), 3);
783    }
784
785    #[test]
786    fn test_ecosystems_query() {
787        let mut sbom = Sbom::default();
788
789        let mut c1 = Component::new("lodash".into(), Some("1.0".into()));
790        c1.ecosystem = Some("npm".into());
791        let mut c2 = Component::new("serde".into(), Some("1.0".into()));
792        c2.ecosystem = Some("cargo".into());
793        let mut c3 = Component::new("other-npm".into(), Some("1.0".into()));
794        c3.ecosystem = Some("npm".into());
795        let c4 = Component::new("no-ecosystem".into(), Some("1.0".into()));
796
797        sbom.components.insert(c1.id.clone(), c1);
798        sbom.components.insert(c2.id.clone(), c2);
799        sbom.components.insert(c3.id.clone(), c3);
800        sbom.components.insert(c4.id.clone(), c4);
801
802        let ecosystems = sbom.ecosystems();
803        assert_eq!(ecosystems.len(), 2);
804        assert!(ecosystems.contains("npm"));
805        assert!(ecosystems.contains("cargo"));
806    }
807
808    #[test]
809    fn test_licenses_query() {
810        let mut sbom = Sbom::default();
811
812        let mut c1 = Component::new("a".into(), Some("1.0".into()));
813        c1.licenses.insert("MIT".into());
814        c1.licenses.insert("Apache-2.0".into());
815        let mut c2 = Component::new("b".into(), Some("1.0".into()));
816        c2.licenses.insert("MIT".into());
817        c2.licenses.insert("GPL-3.0-only".into());
818        let c3 = Component::new("c".into(), Some("1.0".into()));
819
820        sbom.components.insert(c1.id.clone(), c1);
821        sbom.components.insert(c2.id.clone(), c2);
822        sbom.components.insert(c3.id.clone(), c3);
823
824        let licenses = sbom.licenses();
825        assert_eq!(licenses.len(), 3);
826        assert!(licenses.contains("MIT"));
827        assert!(licenses.contains("Apache-2.0"));
828        assert!(licenses.contains("GPL-3.0-only"));
829    }
830
831    #[test]
832    fn test_by_purl() {
833        let mut sbom = Sbom::default();
834
835        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
836        c1.purl = Some("pkg:npm/lodash@4.17.21".into());
837        c1.id = ComponentId::new(c1.purl.as_deref(), &[]);
838        let c2 = Component::new("no-purl".into(), Some("1.0".into()));
839
840        sbom.components.insert(c1.id.clone(), c1);
841        sbom.components.insert(c2.id.clone(), c2);
842
843        let found = sbom.by_purl("pkg:npm/lodash@4.17.21");
844        assert!(found.is_some());
845        assert_eq!(found.unwrap().name, "lodash");
846
847        assert!(sbom.by_purl("pkg:npm/nonexistent@1.0").is_none());
848    }
849
850    #[test]
851    fn test_component_id_unparseable_purl() {
852        // a purl string that can't be parsed should still be used as-is
853        let id = ComponentId::new(Some("not-a-valid-purl-but-still-a-string"), &[]);
854        assert_eq!(id.as_str(), "not-a-valid-purl-but-still-a-string");
855    }
856
857    #[test]
858    fn test_component_id_display() {
859        let id = ComponentId::new(Some("pkg:npm/foo@1.0"), &[]);
860        assert_eq!(format!("{}", id), "pkg:npm/foo@1.0");
861    }
862
863    #[test]
864    fn test_sbom_normalize_clears_metadata() {
865        let mut sbom = Sbom::default();
866        sbom.metadata.timestamp = Some("2024-01-01T00:00:00Z".into());
867        sbom.metadata.tools.push("syft".into());
868        sbom.metadata.authors.push("alice".into());
869
870        let c = Component::new("a".into(), Some("1".into()));
871        sbom.components.insert(c.id.clone(), c);
872
873        sbom.normalize();
874
875        assert!(sbom.metadata.timestamp.is_none());
876        assert!(sbom.metadata.tools.is_empty());
877        assert!(sbom.metadata.authors.is_empty());
878    }
879
880    #[test]
881    fn test_missing_hashes_mixed() {
882        let mut sbom = Sbom::default();
883
884        let c1 = Component::new("no-hash".into(), Some("1.0".into()));
885        let mut c2 = Component::new("has-hash".into(), Some("1.0".into()));
886        c2.hashes.insert("sha256".into(), "abc".into());
887
888        sbom.components.insert(c1.id.clone(), c1);
889        sbom.components.insert(c2.id.clone(), c2);
890
891        let missing = sbom.missing_hashes();
892        assert_eq!(missing.len(), 1);
893    }
894
895    #[test]
896    fn test_ecosystem_from_purl() {
897        use super::ecosystem_from_purl;
898
899        assert_eq!(
900            ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
901            Some("npm".to_string())
902        );
903        assert_eq!(
904            ecosystem_from_purl("pkg:cargo/serde@1.0.0"),
905            Some("cargo".to_string())
906        );
907        assert_eq!(
908            ecosystem_from_purl("pkg:pypi/requests@2.28.0"),
909            Some("pypi".to_string())
910        );
911        assert_eq!(
912            ecosystem_from_purl("pkg:maven/org.apache/commons@1.0"),
913            Some("maven".to_string())
914        );
915        assert_eq!(ecosystem_from_purl("invalid-purl"), None);
916        assert_eq!(ecosystem_from_purl(""), None);
917    }
918
919    #[test]
920    fn test_canonical_algorithm_name() {
921        // SHA family without hyphens (SPDX style)
922        assert_eq!(canonical_algorithm_name("SHA256"), "SHA-256");
923        assert_eq!(canonical_algorithm_name("SHA1"), "SHA-1");
924        assert_eq!(canonical_algorithm_name("SHA384"), "SHA-384");
925        assert_eq!(canonical_algorithm_name("SHA512"), "SHA-512");
926        assert_eq!(canonical_algorithm_name("SHA224"), "SHA-224");
927
928        // SHA family with hyphens (CycloneDX style)
929        assert_eq!(canonical_algorithm_name("SHA-256"), "SHA-256");
930        assert_eq!(canonical_algorithm_name("SHA-1"), "SHA-1");
931        assert_eq!(canonical_algorithm_name("SHA-384"), "SHA-384");
932
933        // case-insensitive
934        assert_eq!(canonical_algorithm_name("sha256"), "SHA-256");
935        assert_eq!(canonical_algorithm_name("sha-256"), "SHA-256");
936
937        // SHA-3
938        assert_eq!(canonical_algorithm_name("SHA3-256"), "SHA3-256");
939        assert_eq!(canonical_algorithm_name("SHA3256"), "SHA3-256");
940
941        // MD family
942        assert_eq!(canonical_algorithm_name("MD5"), "MD5");
943        assert_eq!(canonical_algorithm_name("md5"), "MD5");
944
945        // BLAKE
946        assert_eq!(canonical_algorithm_name("BLAKE2b-256"), "BLAKE2b-256");
947        assert_eq!(canonical_algorithm_name("BLAKE2B256"), "BLAKE2b-256");
948        assert_eq!(canonical_algorithm_name("BLAKE3"), "BLAKE3");
949
950        // ADLER
951        assert_eq!(canonical_algorithm_name("ADLER32"), "ADLER-32");
952        assert_eq!(canonical_algorithm_name("ADLER-32"), "ADLER-32");
953
954        // unknown algorithm passes through
955        assert_eq!(canonical_algorithm_name("TIGER"), "TIGER");
956    }
957
958    #[test]
959    fn test_hash_algorithm_strength_ordering() {
960        // task-specified ordering: MD5 < SHA-1 < SHA-224 < SHA-256 < SHA-384 < SHA-512
961        let md5 = hash_algorithm_strength("MD5").unwrap();
962        let sha1 = hash_algorithm_strength("SHA-1").unwrap();
963        let sha224 = hash_algorithm_strength("SHA-224").unwrap();
964        let sha256 = hash_algorithm_strength("SHA-256").unwrap();
965        let sha384 = hash_algorithm_strength("SHA-384").unwrap();
966        let sha512 = hash_algorithm_strength("SHA-512").unwrap();
967
968        assert!(md5 < sha1);
969        assert!(sha1 < sha224);
970        assert!(sha224 < sha256);
971        assert!(sha256 < sha384);
972        assert!(sha384 < sha512);
973    }
974
975    #[test]
976    fn test_hash_algorithm_strength_variants() {
977        // case and hyphenation variants resolve to same strength
978        assert_eq!(
979            hash_algorithm_strength("sha256"),
980            hash_algorithm_strength("SHA-256")
981        );
982        assert_eq!(
983            hash_algorithm_strength("sha-1"),
984            hash_algorithm_strength("SHA1")
985        );
986
987        // SHA-3 at same tier as SHA-2 equivalent
988        assert_eq!(
989            hash_algorithm_strength("SHA3-256"),
990            hash_algorithm_strength("SHA-256")
991        );
992        assert_eq!(
993            hash_algorithm_strength("SHA3-512"),
994            hash_algorithm_strength("SHA-512")
995        );
996
997        // BLAKE at same tier as SHA-2 equivalent
998        assert_eq!(
999            hash_algorithm_strength("BLAKE2b-256"),
1000            hash_algorithm_strength("SHA-256")
1001        );
1002        assert_eq!(
1003            hash_algorithm_strength("BLAKE3"),
1004            hash_algorithm_strength("SHA-256")
1005        );
1006
1007        // unknown returns None
1008        assert_eq!(hash_algorithm_strength("TIGER"), None);
1009        assert_eq!(hash_algorithm_strength("UNKNOWN"), None);
1010    }
1011
1012    #[test]
1013    fn test_hash_algorithm_strength_adler() {
1014        let adler = hash_algorithm_strength("ADLER-32").unwrap();
1015        let md5 = hash_algorithm_strength("MD5").unwrap();
1016        assert!(adler < md5);
1017    }
1018
1019    #[test]
1020    fn test_is_hash_algorithm_downgrade_sha256_to_md5() {
1021        let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1022        let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1023        assert!(is_hash_algorithm_downgrade(&old, &new));
1024    }
1025
1026    #[test]
1027    fn test_is_hash_algorithm_downgrade_upgrade_not_flagged() {
1028        let old: BTreeMap<String, String> = [("sha-1".into(), "abc".into())].into();
1029        let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1030        assert!(!is_hash_algorithm_downgrade(&old, &new));
1031    }
1032
1033    #[test]
1034    fn test_is_hash_algorithm_downgrade_same_algorithm() {
1035        let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1036        let new: BTreeMap<String, String> = [("sha-256".into(), "def".into())].into();
1037        assert!(!is_hash_algorithm_downgrade(&old, &new));
1038    }
1039
1040    #[test]
1041    fn test_is_hash_algorithm_downgrade_empty_old() {
1042        let old: BTreeMap<String, String> = BTreeMap::new();
1043        let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1044        assert!(!is_hash_algorithm_downgrade(&old, &new));
1045    }
1046
1047    #[test]
1048    fn test_is_hash_algorithm_downgrade_empty_new() {
1049        let old: BTreeMap<String, String> = [("sha-256".into(), "abc".into())].into();
1050        let new: BTreeMap<String, String> = BTreeMap::new();
1051        assert!(!is_hash_algorithm_downgrade(&old, &new));
1052    }
1053
1054    #[test]
1055    fn test_is_hash_algorithm_downgrade_multi_algorithm() {
1056        // old has SHA-256 + MD5, new has only MD5 → downgrade (strongest dropped)
1057        let old: BTreeMap<String, String> = [
1058            ("sha-256".into(), "abc".into()),
1059            ("md5".into(), "xyz".into()),
1060        ]
1061        .into();
1062        let new: BTreeMap<String, String> = [("md5".into(), "def".into())].into();
1063        assert!(is_hash_algorithm_downgrade(&old, &new));
1064    }
1065
1066    #[test]
1067    fn test_is_hash_algorithm_downgrade_multi_algorithm_kept() {
1068        // old has SHA-256 + MD5, new has SHA-256 + SHA-1 → not a downgrade
1069        let old: BTreeMap<String, String> = [
1070            ("sha-256".into(), "abc".into()),
1071            ("md5".into(), "xyz".into()),
1072        ]
1073        .into();
1074        let new: BTreeMap<String, String> = [
1075            ("sha-256".into(), "def".into()),
1076            ("sha-1".into(), "ghi".into()),
1077        ]
1078        .into();
1079        assert!(!is_hash_algorithm_downgrade(&old, &new));
1080    }
1081
1082    #[test]
1083    fn test_detect_cycles_none() {
1084        let mut sbom = Sbom::default();
1085        let c1 = Component::new("a".into(), Some("1".into()));
1086        let c2 = Component::new("b".into(), Some("1".into()));
1087        let c3 = Component::new("c".into(), Some("1".into()));
1088
1089        let id1 = c1.id.clone();
1090        let id2 = c2.id.clone();
1091        let id3 = c3.id.clone();
1092
1093        sbom.components.insert(id1.clone(), c1);
1094        sbom.components.insert(id2.clone(), c2);
1095        sbom.components.insert(id3.clone(), c3);
1096
1097        // a -> b -> c (no cycle)
1098        sbom.dependencies
1099            .entry(id1.clone())
1100            .or_default()
1101            .insert(id2.clone(), DependencyKind::Runtime);
1102        sbom.dependencies
1103            .entry(id2.clone())
1104            .or_default()
1105            .insert(id3.clone(), DependencyKind::Runtime);
1106
1107        assert!(sbom.detect_cycles().is_empty());
1108    }
1109
1110    #[test]
1111    fn test_detect_cycles_simple() {
1112        let mut sbom = Sbom::default();
1113        let c1 = Component::new("a".into(), Some("1".into()));
1114        let c2 = Component::new("b".into(), Some("1".into()));
1115
1116        let id1 = c1.id.clone();
1117        let id2 = c2.id.clone();
1118
1119        sbom.components.insert(id1.clone(), c1);
1120        sbom.components.insert(id2.clone(), c2);
1121
1122        // a -> b -> a (cycle)
1123        sbom.dependencies
1124            .entry(id1.clone())
1125            .or_default()
1126            .insert(id2.clone(), DependencyKind::Runtime);
1127        sbom.dependencies
1128            .entry(id2.clone())
1129            .or_default()
1130            .insert(id1.clone(), DependencyKind::Runtime);
1131
1132        let cycles = sbom.detect_cycles();
1133        assert_eq!(cycles.len(), 1);
1134        // cycle should start and end with the same node
1135        assert_eq!(cycles[0].first(), cycles[0].last());
1136    }
1137
1138    #[test]
1139    fn test_detect_cycles_self_loop() {
1140        let mut sbom = Sbom::default();
1141        let c1 = Component::new("a".into(), Some("1".into()));
1142        let id1 = c1.id.clone();
1143        sbom.components.insert(id1.clone(), c1);
1144
1145        // a -> a (self-loop)
1146        sbom.dependencies
1147            .entry(id1.clone())
1148            .or_default()
1149            .insert(id1.clone(), DependencyKind::Runtime);
1150
1151        let cycles = sbom.detect_cycles();
1152        assert_eq!(cycles.len(), 1);
1153        assert_eq!(cycles[0].len(), 2); // [a, a]
1154    }
1155
1156    #[test]
1157    fn test_detect_cycles_empty_graph() {
1158        let sbom = Sbom::default();
1159        assert!(sbom.detect_cycles().is_empty());
1160    }
1161
1162    #[test]
1163    fn test_detect_cycles_three_node() {
1164        let mut sbom = Sbom::default();
1165        let c1 = Component::new("a".into(), Some("1".into()));
1166        let c2 = Component::new("b".into(), Some("1".into()));
1167        let c3 = Component::new("c".into(), Some("1".into()));
1168
1169        let id1 = c1.id.clone();
1170        let id2 = c2.id.clone();
1171        let id3 = c3.id.clone();
1172
1173        sbom.components.insert(id1.clone(), c1);
1174        sbom.components.insert(id2.clone(), c2);
1175        sbom.components.insert(id3.clone(), c3);
1176
1177        // a -> b -> c -> a (three-node cycle)
1178        sbom.dependencies
1179            .entry(id1.clone())
1180            .or_default()
1181            .insert(id2.clone(), DependencyKind::Runtime);
1182        sbom.dependencies
1183            .entry(id2.clone())
1184            .or_default()
1185            .insert(id3.clone(), DependencyKind::Runtime);
1186        sbom.dependencies
1187            .entry(id3.clone())
1188            .or_default()
1189            .insert(id1.clone(), DependencyKind::Runtime);
1190
1191        let cycles = sbom.detect_cycles();
1192        assert_eq!(cycles.len(), 1);
1193        assert_eq!(cycles[0].first(), cycles[0].last());
1194        assert_eq!(cycles[0].len(), 4); // [a, b, c, a]
1195    }
1196
1197    #[test]
1198    fn test_is_hash_algorithm_downgrade_unknown_algorithms() {
1199        // both have only unknown algorithms → false (can't determine ordering)
1200        let old: BTreeMap<String, String> = [("TIGER".into(), "abc".into())].into();
1201        let new: BTreeMap<String, String> = [("WHIRLPOOL".into(), "def".into())].into();
1202        assert!(!is_hash_algorithm_downgrade(&old, &new));
1203    }
1204}