Skip to main content

sbom_model_cyclonedx/
lib.rs

1#![doc = include_str!("../readme.md")]
2
3use sbom_model::{parse_license_expression, Component, ComponentId, Sbom};
4use std::collections::{BTreeMap, BTreeSet};
5use std::io::Read;
6use thiserror::Error;
7
8/// Errors that can occur when parsing CycloneDX documents.
9#[derive(Error, Debug)]
10pub enum Error {
11    /// The JSON structure doesn't match the CycloneDX schema.
12    #[error("CycloneDX parse error: {0}")]
13    Parse(#[from] cyclonedx_bom::errors::JsonReadError),
14    /// An I/O error occurred while reading the input.
15    #[error("IO error: {0}")]
16    Io(#[from] std::io::Error),
17    /// Internal normalization failed.
18    #[error("Normalization error: {0}")]
19    Normalization(String),
20}
21
22/// Parser for CycloneDX JSON documents.
23///
24/// Converts CycloneDX 1.4+ JSON into the format-agnostic [`Sbom`] type.
25pub struct CycloneDxReader;
26
27impl CycloneDxReader {
28    /// Parses a CycloneDX JSON document from a reader.
29    ///
30    /// # Example
31    ///
32    /// ```
33    /// use sbom_model_cyclonedx::CycloneDxReader;
34    ///
35    /// let json = r#"{
36    ///     "bomFormat": "CycloneDX",
37    ///     "specVersion": "1.4",
38    ///     "version": 1,
39    ///     "components": []
40    /// }"#;
41    ///
42    /// let sbom = CycloneDxReader::read_json(json.as_bytes()).unwrap();
43    /// ```
44    pub fn read_json<R: Read>(reader: R) -> Result<Sbom, Error> {
45        let bom = cyclonedx_bom::prelude::Bom::parse_from_json(reader)?;
46
47        let mut sbom = Sbom::default();
48
49        // 1. Process Metadata
50        if let Some(meta) = bom.metadata {
51            if let Some(timestamp) = meta.timestamp {
52                sbom.metadata.timestamp = Some(timestamp.to_string());
53            }
54            // TODO: Fix Tools API access (changed in 0.6)
55            /*
56            if let Some(tools) = meta.tools {
57                 for tool in tools.0 {
58                    let mut s = String::new();
59                     if let Some(v) = &tool.vendor { s.push_str(&v.to_string()); s.push(' '); }
60                     if let Some(n) = &tool.name { s.push_str(&n.to_string()); }
61                     if let Some(v) = &tool.version { s.push(' '); s.push_str(&v.to_string()); }
62                     sbom.metadata.tools.push(s.trim().to_string());
63                 }
64            }
65            */
66            if let Some(authors) = meta.authors {
67                for author in authors {
68                    let mut s = String::new();
69                    if let Some(n) = &author.name {
70                        s.push_str(n.as_ref());
71                    }
72                    if let Some(e) = &author.email {
73                        s.push_str(" <");
74                        s.push_str(e.as_ref());
75                        s.push('>');
76                    }
77                    sbom.metadata.authors.push(s.trim().to_string());
78                }
79            }
80        }
81
82        // 2. Process Components
83        if let Some(components) = bom.components {
84            for cdx_comp in components.0 {
85                let name = cdx_comp.name.to_string();
86                let version = cdx_comp.version.as_ref().map(|v| v.to_string());
87
88                let mut props = vec![("name", name.as_str())];
89                let v_str = version.clone().unwrap_or_default();
90                if version.is_some() {
91                    props.push(("version", v_str.as_str()));
92                }
93
94                let supplier = cdx_comp
95                    .supplier
96                    .as_ref()
97                    .map(|s| s.name.as_ref().map(|n| n.to_string()).unwrap_or_default());
98                let s_str = supplier.clone().unwrap_or_default();
99                if supplier.is_some() {
100                    props.push(("supplier", s_str.as_str()));
101                }
102
103                let purl = cdx_comp.purl.as_ref().map(|p| p.to_string());
104                let purl_str = purl.as_deref();
105
106                // Extract ecosystem from purl
107                let ecosystem = purl_str.and_then(sbom_model::ecosystem_from_purl);
108
109                let id = ComponentId::new(purl_str, &props);
110
111                let mut comp = Component {
112                    id: id.clone(),
113                    name,
114                    version,
115                    ecosystem,
116                    supplier,
117                    description: cdx_comp.description.as_ref().map(|d| d.to_string()),
118                    purl,
119                    licenses: BTreeSet::new(),
120                    hashes: BTreeMap::new(),
121                    source_ids: Vec::new(),
122                };
123
124                if let Some(bom_ref) = cdx_comp.bom_ref {
125                    comp.source_ids.push(bom_ref.to_string());
126                }
127
128                if let Some(licenses) = cdx_comp.licenses {
129                    for license_choice in licenses.0 {
130                        match license_choice {
131                            cyclonedx_bom::models::license::LicenseChoice::License(l) => {
132                                let li = l.license_identifier;
133                                let s = match li {
134                                    cyclonedx_bom::models::license::LicenseIdentifier::Name(n) => {
135                                        n.to_string()
136                                    }
137                                    cyclonedx_bom::models::license::LicenseIdentifier::SpdxId(
138                                        id,
139                                    ) => id.to_string(),
140                                };
141                                comp.licenses.insert(s);
142                            }
143                            cyclonedx_bom::models::license::LicenseChoice::Expression(e) => {
144                                comp.licenses
145                                    .extend(parse_license_expression(&e.to_string()));
146                            }
147                        }
148                    }
149                }
150
151                if let Some(hashes) = cdx_comp.hashes {
152                    for h in hashes.0 {
153                        comp.hashes.insert(h.alg.to_string(), h.content.0);
154                    }
155                }
156
157                sbom.components.insert(id, comp);
158            }
159        }
160
161        // 3. Process Dependencies
162        // This is tricky because CDX uses bom-refs for dependency graph.
163        // We need to map bom-refs to our ComponentIds.
164
165        // Build a map of bom-ref -> ComponentId
166        let mut ref_map = BTreeMap::new();
167        for (id, comp) in &sbom.components {
168            for src_id in &comp.source_ids {
169                ref_map.insert(src_id.clone(), id.clone());
170            }
171        }
172
173        if let Some(dependencies) = bom.dependencies {
174            for dep in dependencies.0 {
175                let parent_ref = dep.dependency_ref;
176                if let Some(parent_id) = ref_map.get(&parent_ref.to_string()) {
177                    let mut children = BTreeSet::new();
178                    // dependencies is Vec<String>
179                    for child_ref in dep.dependencies {
180                        if let Some(child_id) = ref_map.get(&child_ref.to_string()) {
181                            children.insert(child_id.clone());
182                        }
183                    }
184                    if !children.is_empty() {
185                        sbom.dependencies.insert(parent_id.clone(), children);
186                    }
187                }
188            }
189        }
190
191        Ok(sbom)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_read_minimal_json() {
201        let json = r#"{
202            "bomFormat": "CycloneDX",
203            "specVersion": "1.4",
204            "version": 1,
205            "components": [
206                {
207                    "type": "library",
208                    "name": "pkg-a",
209                    "version": "1.0.0"
210                }
211            ]
212        }"#;
213        let sbom = CycloneDxReader::read_json(json.as_bytes()).unwrap();
214        assert_eq!(sbom.components.len(), 1);
215        assert_eq!(sbom.components[0].name, "pkg-a");
216    }
217
218    #[test]
219    fn test_read_complex_json() {
220        let json = r#"{
221            "bomFormat": "CycloneDX",
222            "specVersion": "1.4",
223            "version": 1,
224            "metadata": {
225                "timestamp": "2023-01-01T00:00:00Z",
226                "authors": [{"name": "alice", "email": "alice@example.com"}]
227            },
228            "components": [
229                {
230                    "type": "library",
231                    "name": "pkg-a",
232                    "version": "1.0.0",
233                    "bom-ref": "ref-a",
234                    "hashes": [{"alg": "SHA-256", "content": "abc"}],
235                    "licenses": [{"license": {"id": "MIT"}}]
236                },
237                {
238                    "type": "library",
239                    "name": "pkg-b",
240                    "version": "2.0.0",
241                    "bom-ref": "ref-b"
242                }
243            ],
244            "dependencies": [
245                {
246                    "ref": "ref-a",
247                    "dependsOn": ["ref-b"]
248                }
249            ]
250        }"#;
251        let sbom = CycloneDxReader::read_json(json.as_bytes()).unwrap();
252        assert_eq!(sbom.components.len(), 2);
253        assert!(sbom.dependencies.contains_key(&sbom.components[0].id));
254    }
255
256    #[test]
257    fn test_ecosystem_extracted_from_purl() {
258        let json = r#"{
259            "bomFormat": "CycloneDX",
260            "specVersion": "1.4",
261            "version": 1,
262            "components": [
263                {
264                    "type": "library",
265                    "name": "lodash",
266                    "version": "4.17.21",
267                    "purl": "pkg:npm/lodash@4.17.21"
268                },
269                {
270                    "type": "library",
271                    "name": "serde",
272                    "version": "1.0.0",
273                    "purl": "pkg:cargo/serde@1.0.0"
274                },
275                {
276                    "type": "library",
277                    "name": "no-purl-pkg",
278                    "version": "1.0.0"
279                }
280            ]
281        }"#;
282        let sbom = CycloneDxReader::read_json(json.as_bytes()).unwrap();
283
284        let lodash = sbom
285            .components
286            .values()
287            .find(|c| c.name == "lodash")
288            .unwrap();
289        assert_eq!(lodash.ecosystem, Some("npm".to_string()));
290
291        let serde = sbom
292            .components
293            .values()
294            .find(|c| c.name == "serde")
295            .unwrap();
296        assert_eq!(serde.ecosystem, Some("cargo".to_string()));
297
298        let no_purl = sbom
299            .components
300            .values()
301            .find(|c| c.name == "no-purl-pkg")
302            .unwrap();
303        assert_eq!(no_purl.ecosystem, None);
304    }
305}