sbom_tools/parsers/
mod.rs1mod cyclonedx;
30mod detection;
31mod spdx;
32mod spdx3;
33pub mod streaming;
34mod traits;
35
36pub use cyclonedx::CycloneDxParser;
37pub use detection::{DetectionResult, FormatDetector, MIN_CONFIDENCE_THRESHOLD, ParserKind};
38pub use spdx::SpdxParser;
39pub use spdx3::Spdx3Parser;
40pub use streaming::{ParseEvent, ParseProgress, ParsedMetadata, StreamingConfig, StreamingParser};
41pub use traits::{FormatConfidence, FormatDetection, ParseError, SbomParser};
42
43use crate::model::NormalizedSbom;
44use std::path::Path;
45
46#[cfg_attr(feature = "ffi", derive(serde::Serialize, serde::Deserialize))]
51#[derive(Debug, Clone)]
52pub struct DetectedFormat {
53 pub format_name: String,
55 pub confidence: f32,
57 pub variant: Option<String>,
59 pub version: Option<String>,
61 pub warnings: Vec<String>,
63}
64
65#[must_use]
69pub fn detect_format(content: &str) -> Option<DetectedFormat> {
70 let detector = FormatDetector::new();
71 let result = detector.detect_from_content(content);
72
73 if result.can_parse() {
74 Some(DetectedFormat {
75 format_name: result
76 .parser
77 .map(|p| p.name().to_string())
78 .unwrap_or_default(),
79 confidence: result.confidence.value(),
80 variant: result.variant,
81 version: result.version,
82 warnings: result.warnings,
83 })
84 } else {
85 None
86 }
87}
88
89pub(crate) const MAX_SBOM_FILE_SIZE: u64 = 512 * 1024 * 1024;
92
93pub const DECLARED_NO_DEPENDENCIES_PROPERTY: &str = "sbom-tools:declared-no-dependencies";
98
99pub(crate) fn strip_bom(content: &str) -> &str {
106 content.strip_prefix('\u{FEFF}').unwrap_or(content)
107}
108
109pub(crate) fn contains_json_key(content: &str, key: &str) -> bool {
121 let quoted = format!("\"{key}\"");
122 let mut search_from = 0;
123 while let Some(rel) = content[search_from..].find(quoted.as_str()) {
124 let after = search_from + rel + quoted.len();
125 if content[after..].trim_start().starts_with(':') {
126 return true;
127 }
128 search_from = after;
129 }
130 false
131}
132
133pub(crate) const MAX_VULN_DESCRIPTION_BYTES: usize = 64 * 1024;
141
142pub(crate) fn capped_description(desc: &Option<String>) -> Option<String> {
145 desc.as_ref().map(|d| {
146 if d.len() <= MAX_VULN_DESCRIPTION_BYTES {
147 return d.clone();
148 }
149 let mut end = MAX_VULN_DESCRIPTION_BYTES;
150 while end > 0 && !d.is_char_boundary(end) {
151 end -= 1;
152 }
153 let mut truncated = d[..end].to_string();
154 truncated.push_str("… [truncated]");
155 truncated
156 })
157}
158
159pub fn parse_sbom(path: &Path) -> Result<NormalizedSbom, ParseError> {
164 let metadata = std::fs::metadata(path).map_err(|e| ParseError::IoError(e.to_string()))?;
165 if metadata.len() > MAX_SBOM_FILE_SIZE {
166 return Err(ParseError::IoError(format!(
167 "SBOM file is {} MB, exceeding the {} MB limit. Split the document or filter it (e.g. `sbom-tools tailor`) before processing.",
168 metadata.len() / (1024 * 1024),
169 MAX_SBOM_FILE_SIZE / (1024 * 1024),
170 )));
171 }
172 let content = std::fs::read_to_string(path).map_err(|e| ParseError::IoError(e.to_string()))?;
173 parse_sbom_str(&content)
174}
175
176pub fn parse_sbom_str(content: &str) -> Result<NormalizedSbom, ParseError> {
180 let detector = FormatDetector::new();
181 detector.parse_str(content)
182}
183
184#[deprecated(
188 since = "0.2.0",
189 note = "Use detect_format() or CycloneDxParser::detect() instead"
190)]
191#[must_use]
192pub fn is_cyclonedx(content: &str) -> bool {
193 CycloneDxParser::new().can_parse(content)
194}
195
196#[deprecated(
198 since = "0.2.0",
199 note = "Use detect_format() or SpdxParser::detect() instead"
200)]
201#[must_use]
202pub fn is_spdx(content: &str) -> bool {
203 SpdxParser::new().can_parse(content)
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn capped_description_truncates_on_char_boundary() {
212 assert_eq!(capped_description(&None), None);
213 assert_eq!(
214 capped_description(&Some("short".to_string())).as_deref(),
215 Some("short")
216 );
217 let big = "é".repeat(MAX_VULN_DESCRIPTION_BYTES); let out = capped_description(&Some(big)).unwrap();
221 assert!(out.len() <= MAX_VULN_DESCRIPTION_BYTES + 16);
222 assert!(out.ends_with("… [truncated]"));
223 assert!(std::str::from_utf8(out.as_bytes()).is_ok());
224 }
225
226 #[test]
227 fn strip_bom_removes_only_a_leading_marker() {
228 assert_eq!(strip_bom("\u{FEFF}{\"a\":1}"), "{\"a\":1}");
229 assert_eq!(strip_bom("{\"a\":1}"), "{\"a\":1}");
230 assert_eq!(strip_bom("{\"a\":\"\u{FEFF}\"}"), "{\"a\":\"\u{FEFF}\"}");
232 }
233
234 #[test]
235 fn contains_json_key_requires_key_position() {
236 assert!(contains_json_key(
237 r#"{"spdxVersion":"SPDX-2.3"}"#,
238 "spdxVersion"
239 ));
240 assert!(contains_json_key(
241 r#"{"spdxVersion" : "SPDX-2.3"}"#,
242 "spdxVersion"
243 ));
244 assert!(!contains_json_key(
246 r#"{"name":"spdxVersion","other":1}"#,
247 "spdxVersion"
248 ));
249 assert!(!contains_json_key("no markers here", "spdxVersion"));
250 }
251
252 #[test]
256 fn bom_prefixed_document_parses_end_to_end() {
257 let content = "\u{FEFF}{\"bomFormat\": \"CycloneDX\", \"specVersion\": \"1.5\", \
258 \"components\": [{\"type\": \"library\", \"name\": \"lib\", \"version\": \"1.0\"}]}";
259 let sbom = parse_sbom_str(content).expect("BOM-prefixed document must parse");
260 assert_eq!(sbom.component_count(), 1);
261 }
262
263 #[test]
264 fn test_detect_cyclonedx_json() {
265 let content = r#"{"bomFormat": "CycloneDX", "specVersion": "1.5"}"#;
266 let detected = detect_format(content).expect("Should detect format");
267 assert_eq!(detected.format_name, "CycloneDX");
268 assert!(detected.confidence >= 0.75);
269 assert_eq!(detected.variant, Some("JSON".to_string()));
270 assert_eq!(detected.version, Some("1.5".to_string()));
271 }
272
273 #[test]
274 fn test_detect_cyclonedx_xml() {
275 let content = r#"<?xml version="1.0" encoding="UTF-8"?>
276<bom xmlns="http://cyclonedx.org/schema/bom/1.5" version="1">
277 <components/>
278</bom>"#;
279 let detected = detect_format(content).expect("Should detect format");
280 assert_eq!(detected.format_name, "CycloneDX");
281 assert!(detected.confidence >= 0.75);
282 assert_eq!(detected.variant, Some("XML".to_string()));
283 }
284
285 #[test]
286 fn test_detect_spdx_json() {
287 let content = r#"{"spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT"}"#;
288 let detected = detect_format(content).expect("Should detect format");
289 assert_eq!(detected.format_name, "SPDX");
290 assert!(detected.confidence >= 0.75);
291 assert_eq!(detected.variant, Some("JSON".to_string()));
292 assert_eq!(detected.version, Some("2.3".to_string()));
293 }
294
295 #[test]
296 fn test_detect_spdx_tag_value() {
297 let content = "SPDXVersion: SPDX-2.3\nDataLicense: CC0-1.0\nSPDXID: SPDXRef-DOCUMENT";
298 let detected = detect_format(content).expect("Should detect format");
299 assert_eq!(detected.format_name, "SPDX");
300 assert!(detected.confidence >= 0.75);
301 assert_eq!(detected.variant, Some("tag-value".to_string()));
302 assert_eq!(detected.version, Some("2.3".to_string()));
303 }
304
305 #[test]
306 fn test_detect_unknown_format() {
307 let content = r#"{"some": "random", "json": "content"}"#;
308 let detected = detect_format(content);
309 assert!(detected.is_none());
310 }
311
312 #[test]
313 fn test_detect_spdx3_json_ld() {
314 let content = r#"{"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", "type": "SpdxDocument", "spdxId": "urn:spdx:doc:test", "creationInfo": {"specVersion": "3.0.1"}}"#;
315 let detected = detect_format(content).expect("Should detect SPDX 3.0 format");
316 assert_eq!(detected.format_name, "SPDX");
317 assert!(detected.confidence >= 0.9);
318 assert_eq!(detected.variant, Some("JSON-LD".to_string()));
319 }
320
321 #[test]
322 fn test_confidence_based_selection() {
323 let cdx_content = r#"{"bomFormat": "CycloneDX", "specVersion": "1.6", "components": []}"#;
325 let cdx_parser = CycloneDxParser::new();
326 let spdx_parser = SpdxParser::new();
327
328 let cdx_conf = cdx_parser.confidence(cdx_content);
329 let spdx_conf = spdx_parser.confidence(cdx_content);
330
331 assert!(cdx_conf.value() > spdx_conf.value());
332 }
333}