Skip to main content

sbom_model_spdx/
lib.rs

1#![doc = include_str!("../readme.md")]
2
3use sbom_model::{parse_license_expression, Component, ComponentId, Sbom};
4use spdx_rs::models::RelationshipType;
5use std::collections::{BTreeMap, BTreeSet};
6use std::io::Read;
7use thiserror::Error;
8
9/// Errors that can occur when parsing SPDX documents.
10#[derive(Error, Debug)]
11pub enum Error {
12    /// The JSON structure doesn't match the SPDX schema.
13    #[error("SPDX parse error: {0}")]
14    Parse(#[from] serde_json::Error),
15    /// An I/O error occurred while reading the input.
16    #[error("IO error: {0}")]
17    Io(#[from] std::io::Error),
18}
19
20/// Parser for SPDX JSON documents.
21///
22/// Converts SPDX 2.3 JSON into the format-agnostic [`Sbom`] type.
23pub struct SpdxReader;
24
25impl SpdxReader {
26    /// Parses an SPDX JSON document from a reader.
27    ///
28    /// # Example
29    ///
30    /// ```no_run
31    /// use sbom_model_spdx::SpdxReader;
32    /// use std::fs::File;
33    ///
34    /// let file = File::open("sbom.spdx.json").unwrap();
35    /// let sbom = SpdxReader::read_json(file).unwrap();
36    /// ```
37    pub fn read_json<R: Read>(reader: R) -> Result<Sbom, Error> {
38        let spdx_doc: spdx_rs::models::SPDX = serde_json::from_reader(reader)?;
39
40        let mut sbom = Sbom::default();
41
42        // 1. Metadata
43        let ci = spdx_doc.document_creation_information.creation_info;
44        sbom.metadata.timestamp = Some(ci.created.to_string());
45        for creator in ci.creators {
46            if let Some(stripped) = creator.strip_prefix("Tool: ") {
47                sbom.metadata.tools.push(stripped.to_string());
48            } else {
49                sbom.metadata.authors.push(creator);
50            }
51        }
52
53        // 2. Components (Packages)
54        for pkg in spdx_doc.package_information {
55            let name = pkg.package_name;
56            let version = pkg.package_version;
57
58            let mut props = vec![("name", name.as_str())];
59            let v_str = version.clone().unwrap_or_default();
60            if version.is_some() {
61                props.push(("version", v_str.as_str()));
62            }
63
64            let supplier = pkg.package_supplier.clone();
65            let s_str = supplier.clone().unwrap_or_default();
66            if supplier.is_some() {
67                props.push(("supplier", s_str.as_str()));
68            }
69
70            // Purl handling
71            let mut purl = None;
72            for r in &pkg.external_reference {
73                if r.reference_type == "purl" {
74                    purl = Some(r.reference_locator.clone());
75                    break;
76                }
77            }
78            let purl_str = purl.as_deref();
79
80            // Extract ecosystem from purl
81            let ecosystem = purl_str.and_then(sbom_model::ecosystem_from_purl);
82
83            let id = ComponentId::new(purl_str, &props);
84
85            let mut comp = Component {
86                id: id.clone(),
87                name,
88                version,
89                ecosystem,
90                supplier,
91                description: None, // pkg.description might not exist or be named differently. Safe fallback.
92                purl,
93                licenses: BTreeSet::new(),
94                hashes: BTreeMap::new(),
95                source_ids: vec![pkg.package_spdx_identifier.clone()],
96            };
97
98            // Try to map description if field matches, else ignore for now to pass build
99            // (If we knew the field name we'd use it)
100
101            // Licenses
102            if let Some(l) = pkg.concluded_license {
103                let l_str = l.to_string();
104                if l_str != "NOASSERTION" && l_str != "NONE" {
105                    comp.licenses.extend(parse_license_expression(&l_str));
106                }
107            }
108
109            // Hashes
110            for checksum in pkg.package_checksum {
111                comp.hashes
112                    .insert(format!("{:?}", checksum.algorithm), checksum.value);
113            }
114
115            sbom.components.insert(id, comp);
116        }
117
118        // 3. Relationships
119        // Map SPDX IDs -> ComponentId
120        let mut ref_map = BTreeMap::new();
121        for (id, comp) in &sbom.components {
122            for src_id in &comp.source_ids {
123                ref_map.insert(src_id.clone(), id.clone());
124            }
125        }
126
127        for rel in spdx_doc.relationships {
128            let parent_spdx = rel.spdx_element_id;
129            let child_spdx = rel.related_spdx_element;
130            let rel_type = rel.relationship_type;
131
132            let is_dependency = matches!(
133                rel_type,
134                RelationshipType::DependsOn
135                    | RelationshipType::Contains
136                    | RelationshipType::Describes
137            );
138
139            if is_dependency {
140                if let (Some(parent_id), Some(child_id)) =
141                    (ref_map.get(&parent_spdx), ref_map.get(&child_spdx))
142                {
143                    sbom.dependencies
144                        .entry(parent_id.clone())
145                        .or_default()
146                        .insert(child_id.clone());
147                }
148            }
149        }
150
151        Ok(sbom)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_read_minimal_json() {
161        let json = r#"{
162            "spdxVersion": "SPDX-2.3",
163            "dataLicense": "CC0-1.0",
164            "SPDXID": "SPDXRef-DOCUMENT",
165            "name": "test",
166            "documentNamespace": "http://spdx.org/spdxdocs/test",
167            "creationInfo": {
168                "creators": ["Tool: manual"],
169                "created": "2023-01-01T00:00:00Z"
170            },
171            "packages": [
172                {
173                    "name": "pkg-a",
174                    "SPDXID": "SPDXRef-pkg-a",
175                    "downloadLocation": "NONE"
176                }
177            ],
178            "relationships": []
179        }"#;
180        let sbom = SpdxReader::read_json(json.as_bytes()).unwrap();
181        assert_eq!(sbom.components.len(), 1);
182        assert_eq!(sbom.components[0].name, "pkg-a");
183    }
184
185    #[test]
186    fn test_read_complex_json() {
187        let json = r#"{
188            "spdxVersion": "SPDX-2.3",
189            "dataLicense": "CC0-1.0",
190            "SPDXID": "SPDXRef-DOCUMENT",
191            "name": "test",
192            "documentNamespace": "http://spdx.org/spdxdocs/test",
193            "creationInfo": {
194                "creators": ["Tool: manual", "Person: bob"],
195                "created": "2023-01-01T00:00:00Z"
196            },
197            "packages": [
198                {
199                    "name": "pkg-a",
200                    "SPDXID": "SPDXRef-pkg-a",
201                    "downloadLocation": "NONE",
202                    "licenseConcluded": "MIT",
203                    "checksums": [{"algorithm": "SHA256", "checksumValue": "abc"}]
204                },
205                {
206                    "name": "pkg-b",
207                    "SPDXID": "SPDXRef-pkg-b",
208                    "downloadLocation": "NONE"
209                }
210            ],
211            "relationships": [
212                {
213                    "spdxElementId": "SPDXRef-pkg-a",
214                    "relatedSpdxElement": "SPDXRef-pkg-b",
215                    "relationshipType": "DEPENDS_ON"
216                }
217            ]
218        }"#;
219        let sbom = SpdxReader::read_json(json.as_bytes()).unwrap();
220        assert_eq!(sbom.components.len(), 2);
221        assert_eq!(sbom.metadata.authors, vec!["Person: bob"]);
222        assert_eq!(sbom.metadata.tools, vec!["manual"]);
223    }
224
225    #[test]
226    fn test_ecosystem_extracted_from_purl() {
227        let json = r#"{
228            "spdxVersion": "SPDX-2.3",
229            "dataLicense": "CC0-1.0",
230            "SPDXID": "SPDXRef-DOCUMENT",
231            "name": "test",
232            "documentNamespace": "http://spdx.org/spdxdocs/test",
233            "creationInfo": {
234                "creators": ["Tool: manual"],
235                "created": "2023-01-01T00:00:00Z"
236            },
237            "packages": [
238                {
239                    "name": "lodash",
240                    "SPDXID": "SPDXRef-lodash",
241                    "versionInfo": "4.17.21",
242                    "downloadLocation": "NONE",
243                    "externalRefs": [
244                        {
245                            "referenceCategory": "PACKAGE-MANAGER",
246                            "referenceType": "purl",
247                            "referenceLocator": "pkg:npm/lodash@4.17.21"
248                        }
249                    ]
250                },
251                {
252                    "name": "no-purl-pkg",
253                    "SPDXID": "SPDXRef-no-purl",
254                    "downloadLocation": "NONE"
255                }
256            ],
257            "relationships": []
258        }"#;
259        let sbom = SpdxReader::read_json(json.as_bytes()).unwrap();
260
261        let lodash = sbom
262            .components
263            .values()
264            .find(|c| c.name == "lodash")
265            .unwrap();
266        assert_eq!(lodash.ecosystem, Some("npm".to_string()));
267
268        let no_purl = sbom
269            .components
270            .values()
271            .find(|c| c.name == "no-purl-pkg")
272            .unwrap();
273        assert_eq!(no_purl.ecosystem, None);
274    }
275}