Skip to main content

sbom_tools/parsers/
mod.rs

1//! SBOM format parsers.
2//!
3//! This module provides parsers for `CycloneDX` and SPDX SBOM formats,
4//! converting them to the normalized intermediate representation.
5//!
6//! ## Format Detection
7//!
8//! The module uses a confidence-based detection system to identify SBOM formats:
9//! - Each parser reports a confidence score (0.0-1.0) for handling content
10//! - The parser with the highest confidence is selected
11//! - Detection includes format variant (JSON, XML, tag-value) and version information
12//!
13//! ## Usage
14//!
15//! ```no_run
16//! use sbom_tools::parsers::{parse_sbom, detect_format};
17//! use std::path::Path;
18//!
19//! // Auto-detect and parse
20//! let sbom = parse_sbom(Path::new("sbom.json")).unwrap();
21//!
22//! // Check format before parsing
23//! let content = std::fs::read_to_string("sbom.json").unwrap();
24//! if let Some(detection) = detect_format(&content) {
25//!     println!("Detected: {} ({})", detection.format_name, detection.confidence);
26//! }
27//! ```
28
29mod 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/// Result of format detection
47///
48/// Note: Serialize/Deserialize support was added in v0.1.18 to support FFI and
49/// external tooling integration. This is a backwards-compatible expansion of the API.
50#[cfg_attr(feature = "ffi", derive(serde::Serialize, serde::Deserialize))]
51#[derive(Debug, Clone)]
52pub struct DetectedFormat {
53    /// Name of the detected format
54    pub format_name: String,
55    /// Confidence score (0.0-1.0)
56    pub confidence: f32,
57    /// Detected variant (e.g., "JSON", "XML", "tag-value")
58    pub variant: Option<String>,
59    /// Detected version if available
60    pub version: Option<String>,
61    /// Any warnings about the detection
62    pub warnings: Vec<String>,
63}
64
65/// Detect SBOM format from content without parsing
66///
67/// Returns None if no format could be detected with sufficient confidence.
68#[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
89/// Maximum SBOM file size (512 MB), enforced to bound memory use when loading a
90/// whole document into a string. Inputs larger than this are rejected.
91pub(crate) const MAX_SBOM_FILE_SIZE: u64 = 512 * 1024 * 1024;
92
93/// Synthetic component property recording that the source document
94/// positively declared the component as having no dependencies (e.g. a
95/// CycloneDX `dependencies` entry with an empty `dependsOn`). Compliance
96/// checks treat such components as documented, not missing from the graph.
97pub const DECLARED_NO_DEPENDENCIES_PROPERTY: &str = "sbom-tools:declared-no-dependencies";
98
99/// Strip a leading UTF-8 BOM (U+FEFF), if present.
100///
101/// `str::trim()` does not remove U+FEFF (it is not `White_Space`), so a
102/// BOM-prefixed but otherwise valid SBOM file fails every parser's
103/// `content.trim().starts_with('{'/'<')` dispatch and is rejected as
104/// unknown format. Every parser's `detect()`/`parse_str()` calls this first.
105pub(crate) fn strip_bom(content: &str) -> &str {
106    content.strip_prefix('\u{FEFF}').unwrap_or(content)
107}
108
109/// Whether `content` contains `key` used as a JSON object key — i.e. a
110/// quoted `key` immediately followed by (optional whitespace, then) `:` —
111/// rather than merely appearing as, or inside, a string *value* anywhere in
112/// the document.
113///
114/// Format detection scans raw text for markers like `"spdxVersion"` without
115/// parsing JSON, so a document whose value happens to equal a marker word
116/// (e.g. a component literally named `"spdxVersion"`, or a property value of
117/// `"SPDXID"`) previously tripped detection for an unrelated format. This
118/// keeps detection allocation-free and streaming-peek-compatible while
119/// requiring the marker to be used as a key, not a coincidental value.
120pub(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
133/// Defensive cap on a single vulnerability description (64 KiB).
134///
135/// A vulnerability's description is attacker-controlled and attached to every
136/// component the vulnerability affects; without a cap, one huge description
137/// deep-cloned across thousands of `affects`/`to` targets is a memory-
138/// amplification DoS. Real CVE descriptions are a paragraph, so 64 KiB is
139/// ~100x headroom while bounding the per-target clone to a constant.
140pub(crate) const MAX_VULN_DESCRIPTION_BYTES: usize = 64 * 1024;
141
142/// Clone an optional description, truncated to [`MAX_VULN_DESCRIPTION_BYTES`]
143/// on a UTF-8 char boundary with a marker appended when truncated.
144pub(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
159/// Detect SBOM format from file content and parse accordingly
160///
161/// Uses confidence-based detection to select the best parser.
162/// Returns an error if the file exceeds [`MAX_SBOM_FILE_SIZE`] to prevent OOM.
163pub 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
176/// Parse SBOM from string content
177///
178/// Uses confidence-based detection to select the best parser.
179pub fn parse_sbom_str(content: &str) -> Result<NormalizedSbom, ParseError> {
180    let detector = FormatDetector::new();
181    detector.parse_str(content)
182}
183
184// Legacy detection functions - kept for backwards compatibility but deprecated
185
186/// Check if content looks like `CycloneDX`
187#[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/// Check if content looks like SPDX
197#[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        // Multi-byte char straddling the cap must not panic and must produce
218        // valid UTF-8.
219        let big = "é".repeat(MAX_VULN_DESCRIPTION_BYTES); // 2 bytes each
220        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        // A BOM elsewhere in the content (not at the start) is untouched.
231        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        // A value equal to the marker word must NOT count as a key.
245        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    /// A BOM-prefixed CycloneDX document must parse successfully end to end,
253    /// not just detect correctly — the dispatch check and the actual
254    /// serde_json::from_str/quick_xml call must both see BOM-free content.
255    #[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        // CycloneDX should have higher confidence for this content
324        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}