1use super::traits::{FormatConfidence, FormatDetection, ParseError, SbomParser};
8use super::{CycloneDxParser, Spdx3Parser, SpdxParser, strip_bom};
9use crate::model::NormalizedSbom;
10use std::io::{BufRead, Read};
11
12fn 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
42pub const MIN_CONFIDENCE_THRESHOLD: f32 = 0.25;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ParserKind {
49 CycloneDx,
50 Spdx,
51 Spdx3,
52}
53
54impl ParserKind {
55 #[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#[derive(Debug, Clone)]
67pub struct DetectionResult {
68 pub parser: Option<ParserKind>,
70 pub confidence: FormatConfidence,
72 pub variant: Option<String>,
74 pub version: Option<String>,
76 pub warnings: Vec<String>,
78}
79
80impl DetectionResult {
81 #[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 #[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 #[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 #[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 #[must_use]
131 pub fn can_parse(&self) -> bool {
132 self.parser.is_some() && self.confidence.value() >= MIN_CONFIDENCE_THRESHOLD
133 }
134}
135
136pub 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 #[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 #[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 #[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 #[must_use]
194 pub fn detect_from_peek(&self, peek: &[u8]) -> DetectionResult {
195 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 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 let preview = String::from_utf8_lossy(peek);
218 let cdx_detection = self.cyclonedx.detect(&preview);
219 let spdx_detection = self.spdx.detect(&preview);
220 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 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 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 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 pub fn parse_str(&self, content: &str) -> Result<NormalizedSbom, ParseError> {
317 let detection = self.detect_from_content(content);
318
319 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 pub fn parse_reader<R: BufRead>(&self, mut reader: R) -> Result<NormalizedSbom, ParseError> {
345 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 let bom_len = usize::from(peek.starts_with(&[0xEF, 0xBB, 0xBF])) * 3;
360 let detection = self.detect_from_peek(peek);
361
362 for warning in &detection.warnings {
364 tracing::warn!("{}", warning);
365 }
366
367 reader.consume(bom_len);
368
369 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 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 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 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 #[must_use]
413 pub const fn cyclonedx_parser(&self) -> &CycloneDxParser {
414 &self.cyclonedx
415 }
416
417 #[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 let content = r#"{"data": "test"}"#;
475 let result = detector.detect_from_content(content);
476
477 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 let content = r#"{"specVersion": "1.5", "components": []}"#;
487 let result = detector.detect_from_content(content);
488
489 if result.confidence.value() < 0.5 {
491 assert!(!result.can_parse());
492 }
493 }
494
495 #[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 #[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 #[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 #[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 #[test]
554 fn genuine_tie_is_reported_ambiguous_not_silently_resolved() {
555 let detector = FormatDetector::new();
556 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}