1use 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#[derive(Debug, Clone)]
49pub struct ParseProgress {
50 pub bytes_read: u64,
52 pub total_bytes: Option<u64>,
54 pub components_parsed: usize,
56 pub dependencies_parsed: usize,
58}
59
60impl ParseProgress {
61 #[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 #[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
78pub type ProgressCallback = Arc<dyn Fn(&ParseProgress) + Send + Sync>;
80
81#[derive(Clone)]
83pub struct StreamingConfig {
84 pub chunk_size: usize,
86 pub component_buffer_size: usize,
88 progress_callback: Option<ProgressCallback>,
90 pub validate_during_parse: bool,
92 pub skip_malformed: bool,
94}
95
96impl Default for StreamingConfig {
97 fn default() -> Self {
98 Self {
99 chunk_size: 64 * 1024, component_buffer_size: 1000,
101 progress_callback: None,
102 validate_during_parse: true,
103 skip_malformed: false,
104 }
105 }
106}
107
108impl StreamingConfig {
109 #[must_use]
111 pub fn with_chunk_size(mut self, size: usize) -> Self {
112 self.chunk_size = size.max(1024); self
114 }
115
116 #[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 #[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 #[must_use]
135 pub const fn with_validation(mut self, validate: bool) -> Self {
136 self.validate_during_parse = validate;
137 self
138 }
139
140 #[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#[derive(Debug, Clone)]
162pub enum ParseEvent {
163 Metadata(Box<ParsedMetadata>),
172 Component(Box<Component>),
174 Dependency(DependencyEdge),
176 Complete,
178}
179
180#[derive(Debug, Clone)]
182pub struct ParsedMetadata {
183 pub document: DocumentMetadata,
184 pub primary_component_id: Option<CanonicalId>,
185 pub extensions: FormatExtensions,
186}
187
188#[derive(Debug)]
190pub struct StreamingParser {
191 config: StreamingConfig,
192}
193
194impl StreamingParser {
195 #[must_use]
197 pub const fn new(config: StreamingConfig) -> Self {
198 Self { config }
199 }
200
201 #[must_use]
203 pub fn default_config() -> Self {
204 Self::new(StreamingConfig::default())
205 }
206
207 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 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 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 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#[allow(dead_code)]
257pub struct StreamingIterator {
258 state: StreamingState,
260 config: StreamingConfig,
262 progress: ParseProgress,
264 pending: VecDeque<ParseEvent>,
266 complete: bool,
268}
269
270enum StreamingState {
271 Initial(Box<dyn BufRead + Send>),
273 Emitting {
275 sbom: Box<NormalizedSbom>,
276 component_index: usize,
277 dependency_index: usize,
278 metadata_emitted: bool,
279 },
280 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 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 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 match std::mem::replace(&mut self.state, StreamingState::Done) {
360 StreamingState::Initial(reader) => {
361 let detector = FormatDetector::new();
363
364 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 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 let edges_len = sbom.edges.len();
408
409 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 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 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
463pub 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; 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 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 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#[derive(Debug, Clone)]
515pub struct ComponentEstimate {
516 pub estimated_count: usize,
518 pub sampled_count: usize,
520 pub file_size: u64,
522 pub bytes_sampled: usize,
524 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 #[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 #[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}