Skip to main content

sbom_tools/parsers/
detection.rs

1//! Centralized format detection for SBOM parsers.
2//!
3//! This module provides consistent format detection logic used by both
4//! the standard parser and streaming parser, ensuring aligned confidence
5//! thresholds and detection behavior.
6
7use super::traits::{FormatConfidence, FormatDetection, ParseError, SbomParser};
8use super::{CycloneDxParser, Spdx3Parser, SpdxParser, strip_bom};
9use crate::model::NormalizedSbom;
10use std::io::{BufRead, Read};
11
12/// Read a reader into a string, rejecting streams over
13/// [`MAX_SBOM_FILE_SIZE`](super::MAX_SBOM_FILE_SIZE).
14///
15/// Reads incrementally and bails the moment the accumulated length would
16/// exceed the cap, so a hostile multi-GB stream is rejected without buffering
17/// its tail, and a small input never over-allocates. Errors on the overrun
18/// rather than silently truncating.
19fn read_to_string_capped<R: Read>(reader: &mut R) -> Result<String, ParseError> {
20    let limit = super::MAX_SBOM_FILE_SIZE as usize;
21    let mut buf: Vec<u8> = Vec::new();
22    let mut chunk = [0u8; 64 * 1024];
23    loop {
24        let n = reader
25            .read(&mut chunk)
26            .map_err(|e| ParseError::IoError(e.to_string()))?;
27        if n == 0 {
28            break;
29        }
30        if buf.len() + n > limit {
31            return Err(ParseError::IoError(format!(
32                "SBOM stream exceeds the {} MB limit",
33                super::MAX_SBOM_FILE_SIZE / (1024 * 1024),
34            )));
35        }
36        buf.extend_from_slice(&chunk[..n]);
37    }
38    String::from_utf8(buf)
39        .map_err(|e| ParseError::IoError(format!("SBOM stream is not valid UTF-8: {e}")))
40}
41
42/// Minimum confidence threshold for accepting a format detection.
43/// This is LOW confidence (0.25) - the parser believes it might be able to handle the content.
44pub const MIN_CONFIDENCE_THRESHOLD: f32 = 0.25;
45
46/// Parser type identified during detection.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ParserKind {
49    CycloneDx,
50    Spdx,
51    Spdx3,
52}
53
54impl ParserKind {
55    /// Get the human-readable name for this parser.
56    #[must_use]
57    pub const fn name(&self) -> &'static str {
58        match self {
59            Self::CycloneDx => "CycloneDX",
60            Self::Spdx | Self::Spdx3 => "SPDX",
61        }
62    }
63}
64
65/// Result of format detection.
66#[derive(Debug, Clone)]
67pub struct DetectionResult {
68    /// The parser that should handle this content, if detected.
69    pub parser: Option<ParserKind>,
70    /// Confidence level of the detection.
71    pub confidence: FormatConfidence,
72    /// Detected format variant (e.g., "JSON", "XML", "tag-value").
73    pub variant: Option<String>,
74    /// Detected version if available.
75    pub version: Option<String>,
76    /// Any warnings about the detection.
77    pub warnings: Vec<String>,
78}
79
80impl DetectionResult {
81    /// Create a result indicating no format was detected.
82    #[must_use]
83    pub fn unknown(reason: &str) -> Self {
84        Self {
85            parser: None,
86            confidence: FormatConfidence::NONE,
87            variant: None,
88            version: None,
89            warnings: vec![reason.to_string()],
90        }
91    }
92
93    /// Create a result for `CycloneDX` detection.
94    #[must_use]
95    pub fn cyclonedx(detection: FormatDetection) -> Self {
96        Self {
97            parser: Some(ParserKind::CycloneDx),
98            confidence: detection.confidence,
99            variant: detection.variant,
100            version: detection.version,
101            warnings: detection.warnings,
102        }
103    }
104
105    /// Create a result for SPDX 2.x detection.
106    #[must_use]
107    pub fn spdx(detection: FormatDetection) -> Self {
108        Self {
109            parser: Some(ParserKind::Spdx),
110            confidence: detection.confidence,
111            variant: detection.variant,
112            version: detection.version,
113            warnings: detection.warnings,
114        }
115    }
116
117    /// Create a result for SPDX 3.0 detection.
118    #[must_use]
119    pub fn spdx3(detection: FormatDetection) -> Self {
120        Self {
121            parser: Some(ParserKind::Spdx3),
122            confidence: detection.confidence,
123            variant: detection.variant,
124            version: detection.version,
125            warnings: detection.warnings,
126        }
127    }
128
129    /// Check if the detection is confident enough to parse.
130    #[must_use]
131    pub fn can_parse(&self) -> bool {
132        self.parser.is_some() && self.confidence.value() >= MIN_CONFIDENCE_THRESHOLD
133    }
134}
135
136/// Centralized format detector for SBOM content.
137///
138/// Provides consistent detection logic for both standard and streaming parsers.
139pub struct FormatDetector {
140    cyclonedx: CycloneDxParser,
141    spdx: SpdxParser,
142    spdx3: Spdx3Parser,
143    min_confidence: f32,
144}
145
146impl Default for FormatDetector {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl FormatDetector {
153    /// Create a new format detector with default settings.
154    #[must_use]
155    pub const fn new() -> Self {
156        Self {
157            cyclonedx: CycloneDxParser::new(),
158            spdx: SpdxParser::new(),
159            spdx3: Spdx3Parser::new(),
160            min_confidence: MIN_CONFIDENCE_THRESHOLD,
161        }
162    }
163
164    /// Create a format detector with a custom confidence threshold.
165    #[must_use]
166    pub const fn with_threshold(min_confidence: f32) -> Self {
167        Self {
168            cyclonedx: CycloneDxParser::new(),
169            spdx: SpdxParser::new(),
170            spdx3: Spdx3Parser::new(),
171            min_confidence: min_confidence.clamp(0.0, 1.0),
172        }
173    }
174
175    /// Detect format from full content string.
176    ///
177    /// This performs full detection using each parser's `detect()` method
178    /// and picks the single strict winner (see [`Self::select_best_of_three`]).
179    #[must_use]
180    pub fn detect_from_content(&self, content: &str) -> DetectionResult {
181        let content = strip_bom(content);
182        let cdx_detection = self.cyclonedx.detect(content);
183        let spdx_detection = self.spdx.detect(content);
184        let spdx3_detection = self.spdx3.detect(content);
185
186        self.select_best_of_three(cdx_detection, spdx_detection, spdx3_detection)
187    }
188
189    /// Detect format from peeked bytes (for streaming).
190    ///
191    /// This performs detection using a prefix of the content, suitable for
192    /// streaming parsers that can only peek at the beginning of a file.
193    #[must_use]
194    pub fn detect_from_peek(&self, peek: &[u8]) -> DetectionResult {
195        // Find first non-whitespace, non-BOM byte. A UTF-8 BOM (EF BB BF) is
196        // not ASCII whitespace, so without skipping it explicitly it would
197        // become "first_char" and fail every match arm below.
198        let peek = if peek.starts_with(&[0xEF, 0xBB, 0xBF]) {
199            &peek[3..]
200        } else {
201            peek
202        };
203        let first_char = peek.iter().find(|&&b| !b.is_ascii_whitespace());
204
205        match first_char {
206            Some(b'{' | b'<') => {
207                // Convert peek to string for detection
208                let preview = String::from_utf8_lossy(peek);
209                let cdx_detection = self.cyclonedx.detect(&preview);
210                let spdx_detection = self.spdx.detect(&preview);
211                let spdx3_detection = self.spdx3.detect(&preview);
212
213                self.select_best_of_three(cdx_detection, spdx_detection, spdx3_detection)
214            }
215            Some(c) if c.is_ascii_alphabetic() => {
216                // Might be tag-value format (starts with letters like "SPDXVersion:")
217                let preview = String::from_utf8_lossy(peek);
218                let cdx_detection = self.cyclonedx.detect(&preview);
219                let spdx_detection = self.spdx.detect(&preview);
220                // SPDX 3.0 is JSON-LD only; detect() itself no-matches
221                // content that doesn't start with '{', so this is just for
222                // a single unified selection call, not a real candidate.
223                let spdx3_detection = self.spdx3.detect(&preview);
224
225                self.select_best_of_three(cdx_detection, spdx_detection, spdx3_detection)
226            }
227            Some(_) => DetectionResult::unknown("Unrecognized content format"),
228            None => DetectionResult::unknown("Empty content"),
229        }
230    }
231
232    /// Select the best parser among all three candidate detections.
233    ///
234    /// Uses consistent threshold checking and returns an error-like result
235    /// instead of defaulting to a specific parser when ambiguous. Unlike a
236    /// short-circuit (e.g. "return SPDX 3.0 immediately if its confidence is
237    /// HIGH"), all three detections are computed and compared BEFORE any
238    /// selection, so a document that also satisfies another format's markers
239    /// as strongly cannot silently misroute or empty-parse: a genuine tie at
240    /// the top confidence is reported as ambiguous rather than resolved by
241    /// an arbitrary priority order.
242    fn select_best_of_three(
243        &self,
244        cdx_detection: FormatDetection,
245        spdx_detection: FormatDetection,
246        spdx3_detection: FormatDetection,
247    ) -> DetectionResult {
248        let cdx_conf = cdx_detection.confidence.value();
249        let spdx_conf = spdx_detection.confidence.value();
250        let spdx3_conf = spdx3_detection.confidence.value();
251
252        tracing::debug!(
253            "Format detection: CycloneDX={:.2}, SPDX={:.2}, SPDX3={:.2}, threshold={:.2}",
254            cdx_conf,
255            spdx_conf,
256            spdx3_conf,
257            self.min_confidence
258        );
259
260        let max_conf = cdx_conf.max(spdx_conf).max(spdx3_conf);
261
262        if max_conf < self.min_confidence {
263            // No default bias - return unknown if nobody meets threshold
264            let mut result =
265                DetectionResult::unknown("Could not detect SBOM format with sufficient confidence");
266            for (name, conf) in [
267                ("CycloneDX", cdx_conf),
268                ("SPDX", spdx_conf),
269                ("SPDX 3.0", spdx3_conf),
270            ] {
271                if conf > 0.0 {
272                    result.warnings.push(format!(
273                        "{name} detection: {:.0}% confidence (threshold: {:.0}%)",
274                        conf * 100.0,
275                        self.min_confidence * 100.0
276                    ));
277                }
278            }
279            return result;
280        }
281
282        // A tie at the top confidence between two or more formats is
283        // reported as ambiguous rather than resolved by priority order —
284        // this is what closes the misrouting/empty-parse class of bugs.
285        const EPSILON: f32 = 1e-6;
286        let winners: Vec<&str> = [
287            ("CycloneDX", cdx_conf),
288            ("SPDX", spdx_conf),
289            ("SPDX 3.0", spdx3_conf),
290        ]
291        .into_iter()
292        .filter(|&(_, conf)| (conf - max_conf).abs() < EPSILON)
293        .map(|(name, _)| name)
294        .collect();
295
296        if winners.len() > 1 {
297            return DetectionResult::unknown(&format!(
298                "Ambiguous format: {} are equally confident ({:.0}%) — refusing to guess",
299                winners.join(" and "),
300                max_conf * 100.0
301            ));
302        }
303
304        if (cdx_conf - max_conf).abs() < EPSILON {
305            DetectionResult::cyclonedx(cdx_detection)
306        } else if (spdx_conf - max_conf).abs() < EPSILON {
307            DetectionResult::spdx(spdx_detection)
308        } else {
309            DetectionResult::spdx3(spdx3_detection)
310        }
311    }
312
313    /// Parse content using the detected format.
314    ///
315    /// This combines detection and parsing in a single operation.
316    pub fn parse_str(&self, content: &str) -> Result<NormalizedSbom, ParseError> {
317        let detection = self.detect_from_content(content);
318
319        // Log any warnings
320        for warning in &detection.warnings {
321            tracing::warn!("{}", warning);
322        }
323
324        match detection.parser {
325            Some(ParserKind::CycloneDx) if detection.can_parse() => {
326                self.cyclonedx.parse_str(content)
327            }
328            Some(ParserKind::Spdx) if detection.can_parse() => self.spdx.parse_str(content),
329            Some(ParserKind::Spdx3) if detection.can_parse() => self.spdx3.parse_str(content),
330            _ => Err(ParseError::UnknownFormat(
331                "Could not detect SBOM format. Expected CycloneDX or SPDX.".to_string(),
332            )),
333        }
334    }
335
336    /// Parse from a reader using streaming JSON parsing.
337    ///
338    /// Peeks at the content to detect format, then uses the appropriate
339    /// reader-based parser for memory-efficient parsing. All reads are
340    /// bounded by [`MAX_SBOM_FILE_SIZE`](super::MAX_SBOM_FILE_SIZE): unlike
341    /// the path-based `parse_sbom`, this entry point (used by the streaming
342    /// parser) has no up-front `metadata().len()` check, so an unbounded
343    /// `read_to_string` here was an OOM vector for a hostile stream.
344    pub fn parse_reader<R: BufRead>(&self, mut reader: R) -> Result<NormalizedSbom, ParseError> {
345        // Peek at the buffer to detect format
346        let peek = reader
347            .fill_buf()
348            .map_err(|e| ParseError::IoError(e.to_string()))?;
349
350        if peek.is_empty() {
351            return Err(ParseError::IoError("Empty content".to_string()));
352        }
353
354        // detect_from_peek already skips a leading BOM for its own analysis;
355        // separately note its length so it can be consumed from the actual
356        // stream below — parse_json_reader reads straight from `reader` with
357        // no string-level strip_bom() pass, so the BOM must be physically
358        // skipped here or it reaches serde_json and fails to parse.
359        let bom_len = usize::from(peek.starts_with(&[0xEF, 0xBB, 0xBF])) * 3;
360        let detection = self.detect_from_peek(peek);
361
362        // Log any warnings
363        for warning in &detection.warnings {
364            tracing::warn!("{}", warning);
365        }
366
367        reader.consume(bom_len);
368
369        // Cap every downstream read at the file-size limit (+1 to detect
370        // overrun). fill_buf only peeked, so the buffered bytes are preserved.
371        let mut reader = reader.take(super::MAX_SBOM_FILE_SIZE + 1);
372
373        match detection.parser {
374            Some(ParserKind::CycloneDx) if detection.can_parse() => {
375                // Check if it's XML (needs string-based parsing)
376                let is_xml = detection.variant.as_deref() == Some("XML");
377                if is_xml {
378                    let content = read_to_string_capped(&mut reader)?;
379                    self.cyclonedx.parse_str(&content)
380                } else {
381                    self.cyclonedx.parse_json_reader(reader)
382                }
383            }
384            Some(ParserKind::Spdx) if detection.can_parse() => {
385                // Check variant - tag-value and RDF need string-based parsing.
386                // detect() reports the RDF variant as "RDF/XML"; the old
387                // bare-"RDF" match never fired, so RDF/XML streams were
388                // misrouted to the JSON reader and always failed.
389                let needs_string = matches!(
390                    detection.variant.as_deref(),
391                    Some("tag-value" | "RDF" | "RDF/XML")
392                );
393                if needs_string {
394                    let content = read_to_string_capped(&mut reader)?;
395                    self.spdx.parse_str(&content)
396                } else {
397                    self.spdx.parse_json_reader(reader)
398                }
399            }
400            Some(ParserKind::Spdx3) if detection.can_parse() => {
401                // SPDX 3.0 is JSON-LD only - read full content and parse
402                let content = read_to_string_capped(&mut reader)?;
403                self.spdx3.parse_str(&content)
404            }
405            _ => Err(ParseError::UnknownFormat(
406                "Could not detect SBOM format. Expected CycloneDX or SPDX.".to_string(),
407            )),
408        }
409    }
410
411    /// Get a reference to the `CycloneDX` parser.
412    #[must_use]
413    pub const fn cyclonedx_parser(&self) -> &CycloneDxParser {
414        &self.cyclonedx
415    }
416
417    /// Get a reference to the SPDX parser.
418    #[must_use]
419    pub const fn spdx_parser(&self) -> &SpdxParser {
420        &self.spdx
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn test_detect_cyclonedx_json() {
430        let detector = FormatDetector::new();
431        let content = r#"{"bomFormat": "CycloneDX", "specVersion": "1.5"}"#;
432        let result = detector.detect_from_content(content);
433
434        assert_eq!(result.parser, Some(ParserKind::CycloneDx));
435        assert!(result.can_parse());
436        assert_eq!(result.variant, Some("JSON".to_string()));
437    }
438
439    #[test]
440    fn test_detect_spdx_json() {
441        let detector = FormatDetector::new();
442        let content = r#"{"spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT"}"#;
443        let result = detector.detect_from_content(content);
444
445        assert_eq!(result.parser, Some(ParserKind::Spdx));
446        assert!(result.can_parse());
447        assert_eq!(result.variant, Some("JSON".to_string()));
448    }
449
450    #[test]
451    fn test_detect_from_peek_cyclonedx() {
452        let detector = FormatDetector::new();
453        let peek = br#"{"bomFormat": "CycloneDX", "specVersion": "1.5", "components": []}"#;
454        let result = detector.detect_from_peek(peek);
455
456        assert_eq!(result.parser, Some(ParserKind::CycloneDx));
457        assert!(result.can_parse());
458    }
459
460    #[test]
461    fn test_detect_unknown_format() {
462        let detector = FormatDetector::new();
463        let content = r#"{"some": "random", "json": "content"}"#;
464        let result = detector.detect_from_content(content);
465
466        assert!(result.parser.is_none());
467        assert!(!result.can_parse());
468    }
469
470    #[test]
471    fn test_no_default_bias() {
472        let detector = FormatDetector::new();
473        // Ambiguous JSON that doesn't match either format
474        let content = r#"{"data": "test"}"#;
475        let result = detector.detect_from_content(content);
476
477        // Should NOT default to CycloneDX or any other format
478        assert!(result.parser.is_none());
479        assert!(!result.can_parse());
480    }
481
482    #[test]
483    fn test_threshold_enforcement() {
484        let detector = FormatDetector::with_threshold(0.5);
485        // Content with low confidence might not pass higher threshold
486        let content = r#"{"specVersion": "1.5", "components": []}"#;
487        let result = detector.detect_from_content(content);
488
489        // If confidence is below 0.5, should not parse
490        if result.confidence.value() < 0.5 {
491            assert!(!result.can_parse());
492        }
493    }
494
495    /// A UTF-8 BOM must not defeat detection: str::trim() does not strip
496    /// U+FEFF, so a BOM-prefixed valid document previously failed on the
497    /// content.trim().starts_with('{') check in every parser's detect().
498    #[test]
499    fn bom_prefixed_content_still_detects() {
500        let detector = FormatDetector::new();
501        let content = "\u{FEFF}{\"bomFormat\": \"CycloneDX\", \"specVersion\": \"1.5\"}";
502        let result = detector.detect_from_content(content);
503        assert_eq!(result.parser, Some(ParserKind::CycloneDx));
504        assert!(result.can_parse());
505    }
506
507    /// Same BOM guard on the peek/streaming path.
508    #[test]
509    fn bom_prefixed_peek_still_detects() {
510        let detector = FormatDetector::new();
511        let mut peek = vec![0xEF, 0xBB, 0xBF];
512        peek.extend_from_slice(br#"{"bomFormat": "CycloneDX", "specVersion": "1.5"}"#);
513        let result = detector.detect_from_peek(&peek);
514        assert_eq!(result.parser, Some(ParserKind::CycloneDx));
515        assert!(result.can_parse());
516    }
517
518    /// A component/property VALUE that happens to equal a marker word
519    /// ("SPDXID") must not trip SPDX detection on an unrelated CycloneDX
520    /// document — the marker must appear as an actual JSON key.
521    #[test]
522    fn marker_word_as_value_does_not_misroute() {
523        let detector = FormatDetector::new();
524        let content = r#"{"bomFormat": "CycloneDX", "specVersion": "1.5",
525            "components": [{"type": "library", "name": "spdxVersion", "version": "1.0"},
526                            {"type": "library", "name": "SPDXID", "version": "1.0"}]}"#;
527        let result = detector.detect_from_content(content);
528        assert_eq!(
529            result.parser,
530            Some(ParserKind::CycloneDx),
531            "a component merely NAMED 'spdxVersion'/'SPDXID' must not misroute to SPDX"
532        );
533    }
534
535    /// A CycloneDX document that also contains "@context"/"spdx3" as plain
536    /// text (e.g. in a description) must not be misrouted to the SPDX 3.0
537    /// parser and silently produce an empty SBOM.
538    #[test]
539    fn cyclonedx_with_spdx3_looking_text_is_not_misrouted() {
540        let detector = FormatDetector::new();
541        let content = r#"{"bomFormat": "CycloneDX", "specVersion": "1.5",
542            "components": [{"type": "library", "name": "lib", "version": "1.0",
543                "description": "migrated from spdx3 format; see @context notes"}]}"#;
544        let result = detector.detect_from_content(content);
545        assert_eq!(result.parser, Some(ParserKind::CycloneDx));
546        assert!(result.can_parse());
547    }
548
549    /// A genuine tie at the top confidence between two formats must be
550    /// reported as ambiguous, not silently resolved by an arbitrary
551    /// priority order (the previous SPDX3-short-circuit / SPDX-tie-break
552    /// bias).
553    #[test]
554    fn genuine_tie_is_reported_ambiguous_not_silently_resolved() {
555        let detector = FormatDetector::new();
556        // CERTAIN CycloneDX markers (bomFormat + CycloneDX) AND CERTAIN SPDX
557        // markers (spdxVersion + SPDXID keys) in the same document.
558        let content = r#"{"bomFormat": "CycloneDX", "specVersion": "1.5",
559            "spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT"}"#;
560        let result = detector.detect_from_content(content);
561        assert!(
562            result.parser.is_none(),
563            "a genuine tie must not silently pick a parser, got {:?}",
564            result.parser
565        );
566        assert!(!result.can_parse());
567    }
568}