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