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#[derive(Error, Debug)]
11pub enum Error {
12 #[error("SPDX parse error: {0}")]
14 Parse(#[from] serde_json::Error),
15 #[error("IO error: {0}")]
17 Io(#[from] std::io::Error),
18}
19
20pub struct SpdxReader;
24
25impl SpdxReader {
26 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 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 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 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 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, purl,
93 licenses: BTreeSet::new(),
94 hashes: BTreeMap::new(),
95 source_ids: vec![pkg.package_spdx_identifier.clone()],
96 };
97
98 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 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 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}