sbom_model_cyclonedx/
lib.rs1#![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#[derive(Error, Debug)]
10pub enum Error {
11 #[error("CycloneDX parse error: {0}")]
13 Parse(#[from] cyclonedx_bom::errors::JsonReadError),
14 #[error("IO error: {0}")]
16 Io(#[from] std::io::Error),
17 #[error("Normalization error: {0}")]
19 Normalization(String),
20}
21
22pub struct CycloneDxReader;
26
27impl CycloneDxReader {
28 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 if let Some(meta) = bom.metadata {
51 if let Some(timestamp) = meta.timestamp {
52 sbom.metadata.timestamp = Some(timestamp.to_string());
53 }
54 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 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 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 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 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}