Skip to main content

sbom_tools/parsers/
streaming.rs

1//! Streaming SBOM parser for large files.
2//!
3//! This module provides memory-efficient parsing for very large SBOMs by:
4//! - Using streaming JSON/XML parsing (`serde_json::from_reader`)
5//! - Not buffering the entire file into memory as a string
6//! - Yielding results via an iterator interface
7//! - Supporting progress callbacks
8//!
9//! Supports CycloneDX 1.4–1.7 (JSON/XML), SPDX 2.2–2.3 (JSON/tag-value/RDF),
10//! and SPDX 3.0 (JSON-LD). Note: SPDX 3.0 requires full document loading due
11//! to its graph-based element model with cross-references.
12//!
13//! # Usage
14//!
15//! ```no_run
16//! use sbom_tools::parsers::streaming::{StreamingParser, StreamingConfig, ParseEvent};
17//! use std::path::Path;
18//!
19//! let config = StreamingConfig::default()
20//!     .with_chunk_size(64 * 1024)
21//!     .with_progress_callback(|p| println!("Progress: {:.1}%", p.percent()));
22//!
23//! let parser = StreamingParser::new(config);
24//! let stream = parser.parse_file(Path::new("large-sbom.json")).unwrap();
25//!
26//! for event in stream {
27//!     match event {
28//!         Ok(ParseEvent::Metadata(meta)) => println!("Document: {:?}", meta.document.format),
29//!         Ok(ParseEvent::Component(comp)) => println!("Component: {}", comp.name),
30//!         Ok(ParseEvent::Dependency(edge)) => println!("Dependency: {} -> {}", edge.from, edge.to),
31//!         Ok(ParseEvent::Complete) => println!("Done!"),
32//!         Err(e) => eprintln!("Error: {}", e),
33//!     }
34//! }
35//! ```
36
37use super::detection::FormatDetector;
38use super::traits::ParseError;
39use crate::model::{
40    CanonicalId, Component, DependencyEdge, DocumentMetadata, FormatExtensions, NormalizedSbom,
41};
42use std::collections::VecDeque;
43use std::io::{BufRead, BufReader, Read};
44use std::path::Path;
45use std::sync::Arc;
46
47/// Progress information for streaming parsing
48#[derive(Debug, Clone)]
49pub struct ParseProgress {
50    /// Bytes read so far
51    pub bytes_read: u64,
52    /// Total bytes (if known)
53    pub total_bytes: Option<u64>,
54    /// Components parsed so far
55    pub components_parsed: usize,
56    /// Dependencies parsed so far
57    pub dependencies_parsed: usize,
58}
59
60impl ParseProgress {
61    /// Get progress percentage (0-100), or None if total is unknown
62    #[must_use]
63    pub fn percent(&self) -> f32 {
64        match self.total_bytes {
65            Some(total) if total > 0 => (self.bytes_read as f32 / total as f32) * 100.0,
66            _ => 0.0,
67        }
68    }
69
70    /// Check if progress is complete
71    #[must_use]
72    pub fn is_complete(&self) -> bool {
73        self.total_bytes
74            .is_some_and(|total| self.bytes_read >= total)
75    }
76}
77
78/// Progress callback type
79pub type ProgressCallback = Arc<dyn Fn(&ParseProgress) + Send + Sync>;
80
81/// Configuration for streaming parser
82#[derive(Clone)]
83pub struct StreamingConfig {
84    /// Chunk size for reading (default: 64KB)
85    pub chunk_size: usize,
86    /// Buffer size for components (default: 1000)
87    pub component_buffer_size: usize,
88    /// Progress callback (optional)
89    progress_callback: Option<ProgressCallback>,
90    /// Whether to validate components during parsing
91    pub validate_during_parse: bool,
92    /// Skip malformed components instead of erroring
93    pub skip_malformed: bool,
94}
95
96impl Default for StreamingConfig {
97    fn default() -> Self {
98        Self {
99            chunk_size: 64 * 1024, // 64KB
100            component_buffer_size: 1000,
101            progress_callback: None,
102            validate_during_parse: true,
103            skip_malformed: false,
104        }
105    }
106}
107
108impl StreamingConfig {
109    /// Set chunk size for reading
110    #[must_use]
111    pub fn with_chunk_size(mut self, size: usize) -> Self {
112        self.chunk_size = size.max(1024); // Minimum 1KB
113        self
114    }
115
116    /// Set component buffer size
117    #[must_use]
118    pub fn with_buffer_size(mut self, size: usize) -> Self {
119        self.component_buffer_size = size.max(10);
120        self
121    }
122
123    /// Set progress callback
124    #[must_use]
125    pub fn with_progress_callback<F>(mut self, callback: F) -> Self
126    where
127        F: Fn(&ParseProgress) + Send + Sync + 'static,
128    {
129        self.progress_callback = Some(Arc::new(callback));
130        self
131    }
132
133    /// Enable/disable validation during parsing
134    #[must_use]
135    pub const fn with_validation(mut self, validate: bool) -> Self {
136        self.validate_during_parse = validate;
137        self
138    }
139
140    /// Enable/disable skipping malformed components
141    #[must_use]
142    pub const fn with_skip_malformed(mut self, skip: bool) -> Self {
143        self.skip_malformed = skip;
144        self
145    }
146}
147
148impl std::fmt::Debug for StreamingConfig {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("StreamingConfig")
151            .field("chunk_size", &self.chunk_size)
152            .field("component_buffer_size", &self.component_buffer_size)
153            .field("has_progress_callback", &self.progress_callback.is_some())
154            .field("validate_during_parse", &self.validate_during_parse)
155            .field("skip_malformed", &self.skip_malformed)
156            .finish()
157    }
158}
159
160/// Events emitted during streaming parsing
161#[derive(Debug, Clone)]
162pub enum ParseEvent {
163    /// Document metadata has been parsed.
164    ///
165    /// Carries `primary_component_id`/`extensions` alongside the document so
166    /// a consumer reconstructing a full `NormalizedSbom` from the event
167    /// stream (see [`StreamingIterator::collect_sbom`]) does not silently
168    /// lose them — both live on `NormalizedSbom` itself, not on
169    /// `DocumentMetadata`, and previously had no event to travel in at all.
170    /// Boxed to keep this variant from dominating `ParseEvent`'s size.
171    Metadata(Box<ParsedMetadata>),
172    /// A component has been parsed
173    Component(Box<Component>),
174    /// A dependency relationship has been parsed
175    Dependency(DependencyEdge),
176    /// Parsing is complete
177    Complete,
178}
179
180/// Payload for [`ParseEvent::Metadata`].
181#[derive(Debug, Clone)]
182pub struct ParsedMetadata {
183    pub document: DocumentMetadata,
184    pub primary_component_id: Option<CanonicalId>,
185    pub extensions: FormatExtensions,
186}
187
188/// Streaming parser for large SBOMs
189#[derive(Debug)]
190pub struct StreamingParser {
191    config: StreamingConfig,
192}
193
194impl StreamingParser {
195    /// Create a new streaming parser with the given configuration
196    #[must_use]
197    pub const fn new(config: StreamingConfig) -> Self {
198        Self { config }
199    }
200
201    /// Create a streaming parser with default configuration
202    #[must_use]
203    pub fn default_config() -> Self {
204        Self::new(StreamingConfig::default())
205    }
206
207    /// Parse a file and return an iterator of events
208    pub fn parse_file(&self, path: &Path) -> Result<StreamingIterator, ParseError> {
209        let file = std::fs::File::open(path)
210            .map_err(|e| ParseError::IoError(format!("Failed to open file: {e}")))?;
211
212        let total_bytes = file.metadata().map(|m| m.len()).ok();
213        let reader = BufReader::with_capacity(self.config.chunk_size, file);
214
215        self.parse_reader(reader, total_bytes)
216    }
217
218    /// Parse from a reader and return an iterator of events
219    pub fn parse_reader<R: Read + Send + 'static>(
220        &self,
221        reader: BufReader<R>,
222        total_bytes: Option<u64>,
223    ) -> Result<StreamingIterator, ParseError> {
224        Ok(StreamingIterator::new(
225            reader,
226            total_bytes,
227            self.config.clone(),
228        ))
229    }
230
231    /// Parse from string content
232    pub fn parse_str(&self, content: &str) -> Result<StreamingIterator, ParseError> {
233        let cursor = std::io::Cursor::new(content.to_string());
234        let total_bytes = Some(content.len() as u64);
235        let reader = BufReader::new(cursor);
236        self.parse_reader(reader, total_bytes)
237    }
238
239    /// Collect all events into a `NormalizedSbom` (for convenience)
240    ///
241    /// Note: This loads the entire SBOM into memory, negating the
242    /// streaming benefits. Use the iterator directly for large files.
243    pub fn parse_to_sbom(&self, path: &Path) -> Result<NormalizedSbom, ParseError> {
244        let mut stream = self.parse_file(path)?;
245        stream.collect_sbom()
246    }
247}
248
249impl Default for StreamingParser {
250    fn default() -> Self {
251        Self::default_config()
252    }
253}
254
255/// Iterator over streaming parse events
256#[allow(dead_code)]
257pub struct StreamingIterator {
258    /// Internal state
259    state: StreamingState,
260    /// Configuration
261    config: StreamingConfig,
262    /// Progress tracking
263    progress: ParseProgress,
264    /// Pending events
265    pending: VecDeque<ParseEvent>,
266    /// Whether parsing is complete
267    complete: bool,
268}
269
270enum StreamingState {
271    /// Initial state - need to detect format and parse
272    Initial(Box<dyn BufRead + Send>),
273    /// Parsing complete, emitting events from parsed SBOM
274    Emitting {
275        sbom: Box<NormalizedSbom>,
276        component_index: usize,
277        dependency_index: usize,
278        metadata_emitted: bool,
279    },
280    /// Finished
281    Done,
282}
283
284impl StreamingIterator {
285    fn new<R: Read + Send + 'static>(
286        reader: BufReader<R>,
287        total_bytes: Option<u64>,
288        config: StreamingConfig,
289    ) -> Self {
290        Self {
291            state: StreamingState::Initial(Box::new(reader)),
292            config,
293            progress: ParseProgress {
294                bytes_read: 0,
295                total_bytes,
296                components_parsed: 0,
297                dependencies_parsed: 0,
298            },
299            pending: VecDeque::new(),
300            complete: false,
301        }
302    }
303
304    /// Collect all events into a `NormalizedSbom`
305    pub fn collect_sbom(&mut self) -> Result<NormalizedSbom, ParseError> {
306        let mut metadata: Option<DocumentMetadata> = None;
307        let mut primary_component_id: Option<CanonicalId> = None;
308        let mut extensions: Option<FormatExtensions> = None;
309        let mut components = Vec::new();
310        let mut edges = Vec::new();
311
312        for event in self.by_ref() {
313            match event {
314                Ok(ParseEvent::Metadata(meta)) => {
315                    metadata = Some(meta.document);
316                    primary_component_id = meta.primary_component_id;
317                    extensions = Some(meta.extensions);
318                }
319                Ok(ParseEvent::Component(comp)) => components.push(*comp),
320                Ok(ParseEvent::Dependency(edge)) => edges.push(edge),
321                Ok(ParseEvent::Complete) => break,
322                Err(e) => return Err(e),
323            }
324        }
325
326        let document = metadata.unwrap_or_default();
327        let mut sbom = NormalizedSbom::new(document);
328        sbom.primary_component_id = primary_component_id;
329        sbom.extensions = extensions.unwrap_or_default();
330
331        for comp in components {
332            sbom.add_component(comp);
333        }
334        for edge in edges {
335            sbom.add_edge(edge);
336        }
337
338        sbom.calculate_content_hash();
339        Ok(sbom)
340    }
341
342    fn report_progress(&self) {
343        if let Some(ref callback) = self.config.progress_callback {
344            callback(&self.progress);
345        }
346    }
347
348    fn advance(&mut self) -> Option<Result<ParseEvent, ParseError>> {
349        // Return pending events first
350        if let Some(event) = self.pending.pop_front() {
351            return Some(Ok(event));
352        }
353
354        if self.complete {
355            return None;
356        }
357
358        // Process based on state
359        match std::mem::replace(&mut self.state, StreamingState::Done) {
360            StreamingState::Initial(reader) => {
361                // Use centralized FormatDetector for consistent detection
362                let detector = FormatDetector::new();
363
364                // Parse using the detector which handles format detection and parsing
365                match detector.parse_reader(reader) {
366                    Ok(sbom) => {
367                        self.progress.bytes_read = self.progress.total_bytes.unwrap_or(0);
368                        self.report_progress();
369                        self.state = StreamingState::Emitting {
370                            sbom: Box::new(sbom),
371                            component_index: 0,
372                            dependency_index: 0,
373                            metadata_emitted: false,
374                        };
375                        self.advance()
376                    }
377                    Err(e) => Some(Err(e)),
378                }
379            }
380            StreamingState::Emitting {
381                sbom,
382                component_index,
383                dependency_index,
384                metadata_emitted,
385            } => {
386                // Emit metadata first
387                if !metadata_emitted {
388                    let document = sbom.document.clone();
389                    let primary_component_id = sbom.primary_component_id.clone();
390                    let extensions = sbom.extensions.clone();
391                    self.state = StreamingState::Emitting {
392                        sbom,
393                        component_index,
394                        dependency_index,
395                        metadata_emitted: true,
396                    };
397                    return Some(Ok(ParseEvent::Metadata(Box::new(ParsedMetadata {
398                        document,
399                        primary_component_id,
400                        extensions,
401                    }))));
402                }
403
404                // IndexMap allows O(1) positional access; cloning the whole
405                // component map on every advance() (once per emitted event)
406                // was O(n²) time and allocation churn for a streaming parser.
407                let edges_len = sbom.edges.len();
408
409                // Emit components
410                if let Some(comp) = sbom
411                    .components
412                    .get_index(component_index)
413                    .map(|(_, c)| c.clone())
414                {
415                    self.progress.components_parsed += 1;
416                    if self.progress.components_parsed.is_multiple_of(100) {
417                        self.report_progress();
418                    }
419                    self.state = StreamingState::Emitting {
420                        sbom,
421                        component_index: component_index + 1,
422                        dependency_index,
423                        metadata_emitted,
424                    };
425                    return Some(Ok(ParseEvent::Component(Box::new(comp))));
426                }
427
428                // Emit dependencies
429                if dependency_index < edges_len {
430                    let edge = sbom.edges[dependency_index].clone();
431                    self.progress.dependencies_parsed += 1;
432                    self.state = StreamingState::Emitting {
433                        sbom,
434                        component_index,
435                        dependency_index: dependency_index + 1,
436                        metadata_emitted,
437                    };
438                    return Some(Ok(ParseEvent::Dependency(edge)));
439                }
440
441                // All done
442                self.complete = true;
443                self.report_progress();
444                self.state = StreamingState::Done;
445                Some(Ok(ParseEvent::Complete))
446            }
447            StreamingState::Done => {
448                self.complete = true;
449                None
450            }
451        }
452    }
453}
454
455impl Iterator for StreamingIterator {
456    type Item = Result<ParseEvent, ParseError>;
457
458    fn next(&mut self) -> Option<Self::Item> {
459        self.advance()
460    }
461}
462
463/// Estimate the number of components in an SBOM file without full parsing
464///
465/// This performs a quick scan to estimate component count, useful for
466/// progress reporting and memory allocation.
467pub fn estimate_component_count(path: &Path) -> Result<ComponentEstimate, ParseError> {
468    let file = std::fs::File::open(path)
469        .map_err(|e| ParseError::IoError(format!("Failed to open file: {e}")))?;
470
471    let file_size = file.metadata().map(|m| m.len()).unwrap_or(0);
472
473    let mut count = 0;
474    let mut bytes_sampled = 0usize;
475    let sample_limit = 1024 * 1024; // Sample first 1MB
476
477    // Cap the whole read at the sample limit (+1 to see we hit it) BEFORE
478    // splitting into lines. Minified SBOMs are single-line JSON, so
479    // `reader.lines()` on the raw file would allocate the ENTIRE file (e.g.
480    // 10 GB) into one String before the sample check could fire.
481    let reader = BufReader::new(file.take(sample_limit as u64 + 1));
482
483    for line in reader.lines() {
484        let line = line.map_err(|e| ParseError::IoError(e.to_string()))?;
485        bytes_sampled += line.len();
486
487        // Count component markers
488        if line.contains("\"bom-ref\"") || line.contains("\"SPDXID\"") {
489            count += 1;
490        }
491
492        if bytes_sampled > sample_limit {
493            break;
494        }
495    }
496
497    // Extrapolate if we only sampled part of the file
498    let estimated = if bytes_sampled < file_size as usize && bytes_sampled > 0 {
499        (count as f64 * (file_size as f64 / bytes_sampled as f64)) as usize
500    } else {
501        count
502    };
503
504    Ok(ComponentEstimate {
505        estimated_count: estimated,
506        sampled_count: count,
507        file_size,
508        bytes_sampled,
509        is_extrapolated: bytes_sampled < file_size as usize,
510    })
511}
512
513/// Estimate of component count
514#[derive(Debug, Clone)]
515pub struct ComponentEstimate {
516    /// Estimated total component count
517    pub estimated_count: usize,
518    /// Components found in sampled region
519    pub sampled_count: usize,
520    /// Total file size in bytes
521    pub file_size: u64,
522    /// Bytes that were sampled
523    pub bytes_sampled: usize,
524    /// Whether the estimate was extrapolated
525    pub is_extrapolated: bool,
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn test_progress_percent() {
534        let progress = ParseProgress {
535            bytes_read: 50,
536            total_bytes: Some(100),
537            components_parsed: 5,
538            dependencies_parsed: 3,
539        };
540        assert_eq!(progress.percent(), 50.0);
541        assert!(!progress.is_complete());
542
543        let complete = ParseProgress {
544            bytes_read: 100,
545            total_bytes: Some(100),
546            components_parsed: 10,
547            dependencies_parsed: 5,
548        };
549        assert_eq!(complete.percent(), 100.0);
550        assert!(complete.is_complete());
551    }
552
553    #[test]
554    fn test_streaming_config_builder() {
555        let config = StreamingConfig::default()
556            .with_chunk_size(128 * 1024)
557            .with_buffer_size(500)
558            .with_validation(false)
559            .with_skip_malformed(true);
560
561        assert_eq!(config.chunk_size, 128 * 1024);
562        assert_eq!(config.component_buffer_size, 500);
563        assert!(!config.validate_during_parse);
564        assert!(config.skip_malformed);
565    }
566
567    #[test]
568    fn test_streaming_parser_creation() {
569        let parser = StreamingParser::default_config();
570        assert_eq!(parser.config.chunk_size, 64 * 1024);
571    }
572
573    /// The streaming path must not lose `primary_component_id`/`extensions`
574    /// relative to the non-streaming parse of the same input — both live on
575    /// `NormalizedSbom`, not `DocumentMetadata`, and previously had no event
576    /// to travel through at all, so `collect_sbom()` always produced `None`/
577    /// default regardless of what the source document actually declared.
578    #[test]
579    fn collect_sbom_preserves_primary_component_and_extensions() {
580        let cdx = r#"{
581            "bomFormat": "CycloneDX", "specVersion": "1.5",
582            "metadata": {
583                "component": {
584                    "type": "application", "bom-ref": "pkg:npm/app@1.0",
585                    "name": "app", "version": "1.0"
586                }
587            },
588            "components": [
589                {"type": "library", "bom-ref": "pkg:npm/lib@1.0", "name": "lib", "version": "1.0"}
590            ]
591        }"#;
592
593        let non_streaming = crate::parsers::parse_sbom_str(cdx).expect("non-streaming parse");
594        let mut stream = StreamingParser::default_config()
595            .parse_str(cdx)
596            .expect("stream start");
597        let streamed = stream.collect_sbom().expect("collect_sbom");
598
599        assert!(
600            non_streaming.primary_component_id.is_some(),
601            "fixture must actually exercise a primary component"
602        );
603        assert_eq!(
604            streamed.primary_component_id, non_streaming.primary_component_id,
605            "streaming must preserve primary_component_id"
606        );
607        assert_eq!(streamed.component_count(), non_streaming.component_count());
608    }
609
610    /// The no-primary-component case must round-trip as `None`, not panic
611    /// or silently become `Some(default)`.
612    #[test]
613    fn collect_sbom_preserves_absent_primary_component_as_none() {
614        let cdx = r#"{
615            "bomFormat": "CycloneDX", "specVersion": "1.5",
616            "components": [
617                {"type": "library", "bom-ref": "pkg:npm/lib@1.0", "name": "lib", "version": "1.0"}
618            ]
619        }"#;
620
621        let non_streaming = crate::parsers::parse_sbom_str(cdx).expect("non-streaming parse");
622        assert!(
623            non_streaming.primary_component_id.is_none(),
624            "fixture must have no metadata.component"
625        );
626
627        let mut stream = StreamingParser::default_config()
628            .parse_str(cdx)
629            .expect("stream start");
630        let streamed = stream.collect_sbom().expect("collect_sbom");
631        assert!(
632            streamed.primary_component_id.is_none(),
633            "absent primary component must round-trip as None, not Some(default)"
634        );
635    }
636}