1use std::collections::HashSet;
9use std::fs::{self, File};
10use std::hash::Hash;
11use std::io::Read;
12use std::path::Path;
13
14use anyhow::Result;
15use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
16use packageurl::PackageUrl;
17
18pub const MAX_MANIFEST_SIZE: u64 = 100 * 1024 * 1024;
20
21pub const MAX_FIELD_LENGTH: usize = 10 * 1024 * 1024;
23
24pub const MAX_ITERATION_COUNT: usize = 100_000;
26
27pub fn capped_iteration_limit(total_len: usize, context: &str) -> usize {
39 if total_len > MAX_ITERATION_COUNT {
40 crate::parser_warn!(
41 "Truncated {} from {} to {} entries (MAX_ITERATION_COUNT); {} entries dropped",
42 context,
43 total_len,
44 MAX_ITERATION_COUNT,
45 total_len - MAX_ITERATION_COUNT
46 );
47 }
48 total_len.min(MAX_ITERATION_COUNT)
49}
50
51pub struct CappedIter<I: Iterator> {
60 inner: I,
61 context: &'static str,
62 yielded: usize,
63 warned: bool,
64}
65
66impl<I: Iterator> Iterator for CappedIter<I> {
67 type Item = I::Item;
68
69 fn next(&mut self) -> Option<Self::Item> {
70 if self.yielded >= MAX_ITERATION_COUNT {
71 if !self.warned && self.inner.next().is_some() {
74 self.warned = true;
75 crate::parser_warn!(
76 "Truncated {} at {} entries (MAX_ITERATION_COUNT); additional entries dropped",
77 self.context,
78 MAX_ITERATION_COUNT
79 );
80 }
81 return None;
82 }
83 let item = self.inner.next();
84 if item.is_some() {
85 self.yielded += 1;
86 }
87 item
88 }
89
90 fn size_hint(&self) -> (usize, Option<usize>) {
91 let remaining_cap = MAX_ITERATION_COUNT.saturating_sub(self.yielded);
94 let (lower, upper) = self.inner.size_hint();
95 let upper = match upper {
96 Some(upper) => upper.min(remaining_cap),
97 None => remaining_cap,
98 };
99 (lower.min(remaining_cap), Some(upper))
100 }
101}
102
103pub trait CappedIterExt: Iterator + Sized {
105 fn capped(self, context: &'static str) -> CappedIter<Self> {
111 CappedIter {
112 inner: self,
113 context,
114 yielded: 0,
115 warned: false,
116 }
117 }
118}
119
120impl<I: Iterator> CappedIterExt for I {}
121
122pub const MAX_RECURSION_DEPTH: usize = 50;
124
125pub struct RecursionGuard<K: Hash + Eq> {
158 depth: usize,
159 visited: HashSet<K>,
160}
161
162impl<K: Hash + Eq> RecursionGuard<K> {
163 pub fn new() -> Self {
164 Self {
165 depth: 0,
166 visited: HashSet::new(),
167 }
168 }
169
170 pub fn exceeded(&self) -> bool {
171 self.depth > MAX_RECURSION_DEPTH
172 }
173
174 pub fn depth(&self) -> usize {
175 self.depth
176 }
177
178 pub fn enter(&mut self, key: K) -> bool {
179 if self.visited.contains(&key) {
180 return true;
181 }
182 self.visited.insert(key);
183 self.depth += 1;
184 false
185 }
186
187 pub fn leave(&mut self, key: K) {
188 self.visited.remove(&key);
189 self.depth -= 1;
190 }
191}
192
193impl RecursionGuard<()> {
194 pub fn depth_only() -> Self {
195 Self::new()
196 }
197
198 pub fn descend(&mut self) -> bool {
199 self.depth += 1;
200 self.exceeded()
201 }
202
203 pub fn ascend(&mut self) {
204 self.depth -= 1;
205 }
206}
207
208impl<K: Hash + Eq> Default for RecursionGuard<K> {
209 fn default() -> Self {
210 Self::new()
211 }
212}
213
214pub fn truncate_field(value: String) -> String {
218 if value.len() <= MAX_FIELD_LENGTH {
219 return value;
220 }
221 let truncated = &value[..value.floor_char_boundary(MAX_FIELD_LENGTH)];
222 crate::parser_warn!(
223 "Truncated field value from {} bytes to {} bytes (MAX_FIELD_LENGTH)",
224 value.len(),
225 truncated.len()
226 );
227 truncated.to_string()
228}
229
230pub fn read_file_to_string(path: &Path, max_size: Option<u64>) -> Result<String> {
251 let limit = max_size.unwrap_or(MAX_MANIFEST_SIZE);
252
253 let metadata =
254 fs::metadata(path).map_err(|e| anyhow::anyhow!("Cannot stat file {:?}: {}", path, e))?;
255
256 if metadata.len() > limit {
257 anyhow::bail!(
258 "File {:?} is {} bytes, exceeding the {} byte limit",
259 path,
260 metadata.len(),
261 limit
262 );
263 }
264
265 let mut bytes = Vec::with_capacity(metadata.len() as usize);
266 let mut file = File::open(path)?;
267 file.read_to_end(&mut bytes)?;
268
269 match String::from_utf8(bytes) {
270 Ok(s) => Ok(s),
271 Err(err) => {
272 let bytes = err.into_bytes();
273 crate::parser_warn!(
274 "File {:?} contains invalid UTF-8; using lossy conversion",
275 path
276 );
277 Ok(String::from_utf8_lossy(&bytes).into_owned())
278 }
279 }
280}
281
282pub fn url_authority_host(authority: &str) -> &str {
294 authority
295 .rsplit_once('@')
296 .map(|(_, host)| host)
297 .unwrap_or(authority)
298}
299
300pub fn simple_purl(package_type: &str, name: &str, version: Option<&str>) -> Option<String> {
319 let name = truncate_field(name.trim().to_string());
320 if name.is_empty() {
321 return None;
322 }
323
324 let mut package_url = PackageUrl::new(package_type.to_string(), name).ok()?;
325 if let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) {
326 package_url
327 .with_version(truncate_field(version.to_string()))
328 .ok()?;
329 }
330 Some(package_url.to_string())
331}
332
333pub fn namespaced_purl(
341 package_type: &str,
342 namespace: &str,
343 name: &str,
344 version: Option<&str>,
345) -> Option<String> {
346 let namespace = truncate_field(namespace.trim().to_string());
347 let name = truncate_field(name.trim().to_string());
348 if namespace.is_empty() || name.is_empty() {
349 return None;
350 }
351
352 let mut package_url = PackageUrl::new(package_type.to_string(), name).ok()?;
353 package_url.with_namespace(namespace).ok()?;
354 if let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) {
355 package_url
356 .with_version(truncate_field(version.to_string()))
357 .ok()?;
358 }
359 Some(package_url.to_string())
360}
361
362pub fn npm_purl(full_name: &str, version: Option<&str>) -> Option<String> {
367 let (namespace, name) = if full_name.starts_with('@') {
368 let parts: Vec<&str> = full_name.splitn(2, '/').collect();
369 if parts.len() == 2 {
370 (Some(parts[0]), parts[1])
371 } else {
372 (None, full_name)
373 }
374 } else {
375 (None, full_name)
376 };
377
378 let mut purl = PackageUrl::new("npm", name).ok()?;
379
380 if let Some(ns) = namespace {
381 purl.with_namespace(ns).ok()?;
382 }
383
384 if let Some(ver) = version {
385 purl.with_version(ver).ok()?;
386 }
387
388 Some(purl.to_string())
389}
390
391pub fn parse_sri(integrity: &str) -> Option<(String, String)> {
397 let parts: Vec<&str> = integrity.splitn(2, '-').collect();
398 if parts.len() != 2 {
399 return None;
400 }
401
402 let algorithm = parts[0];
403 let base64_str = parts[1];
404
405 let bytes = BASE64_STANDARD.decode(base64_str).ok()?;
406
407 let hex_string = bytes
408 .iter()
409 .map(|b| format!("{:02x}", b))
410 .collect::<String>();
411
412 Some((algorithm.to_string(), hex_string))
413}
414
415pub fn split_name_email(s: &str) -> (Option<String>, Option<String>) {
435 if let Some(email_start) = s.find('<')
436 && let Some(email_end) = s.find('>')
437 && email_start < email_end
438 {
439 let name = s[..email_start].trim();
440 let email = &s[email_start + 1..email_end];
441 (
442 if name.is_empty() {
443 None
444 } else {
445 Some(name.to_string())
446 },
447 Some(email.to_string()),
448 )
449 } else {
450 (Some(s.trim().to_string()), None)
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use std::io::Write;
458 use tempfile::tempdir;
459
460 #[test]
461 fn test_recursion_guard_tracks_depth_and_cycles() {
462 let mut guard = RecursionGuard::new();
463
464 assert_eq!(guard.depth(), 0);
465 assert!(!guard.exceeded());
466
467 assert!(!guard.enter("root"));
468 assert_eq!(guard.depth(), 1);
469 assert!(!guard.enter("child"));
470 assert_eq!(guard.depth(), 2);
471
472 assert!(guard.enter("root"));
473 assert_eq!(guard.depth(), 2);
474
475 guard.leave("child");
476 assert_eq!(guard.depth(), 1);
477 guard.leave("root");
478 assert_eq!(guard.depth(), 0);
479 assert!(!guard.exceeded());
480 }
481
482 #[test]
483 fn test_recursion_guard_depth_limit_and_depth_only_mode() {
484 let mut guard = RecursionGuard::<()>::depth_only();
485
486 for _ in 0..MAX_RECURSION_DEPTH {
487 assert!(!guard.descend());
488 }
489
490 assert_eq!(guard.depth(), MAX_RECURSION_DEPTH);
491 assert!(!guard.exceeded());
492
493 assert!(guard.descend());
494 assert_eq!(guard.depth(), MAX_RECURSION_DEPTH + 1);
495 assert!(guard.exceeded());
496
497 guard.ascend();
498 assert_eq!(guard.depth(), MAX_RECURSION_DEPTH);
499 assert!(!guard.exceeded());
500 }
501
502 #[test]
503 fn test_read_file_to_string_success() {
504 let dir = tempdir().unwrap();
505 let file_path = dir.path().join("test.txt");
506 let mut file = File::create(&file_path).unwrap();
507 file.write_all(b"test content").unwrap();
508
509 let content = read_file_to_string(&file_path, None).unwrap();
510 assert_eq!(content, "test content");
511 }
512
513 #[test]
514 fn test_read_file_to_string_nonexistent() {
515 let path = Path::new("/nonexistent/file.txt");
516 let result = read_file_to_string(path, None);
517 assert!(result.is_err());
518 }
519
520 #[test]
521 fn test_read_file_to_string_empty() {
522 let dir = tempdir().unwrap();
523 let file_path = dir.path().join("empty.txt");
524 File::create(&file_path).unwrap();
525
526 let content = read_file_to_string(&file_path, None).unwrap();
527 assert_eq!(content, "");
528 }
529
530 #[test]
531 fn test_npm_purl_scoped_with_version() {
532 let purl = npm_purl("@babel/core", Some("7.0.0")).unwrap();
533 assert_eq!(purl, "pkg:npm/%40babel/core@7.0.0");
534 }
535
536 #[test]
537 fn test_npm_purl_scoped_without_version() {
538 let purl = npm_purl("@babel/core", None).unwrap();
539 assert_eq!(purl, "pkg:npm/%40babel/core");
540 }
541
542 #[test]
543 fn test_npm_purl_unscoped_with_version() {
544 let purl = npm_purl("lodash", Some("4.17.21")).unwrap();
545 assert_eq!(purl, "pkg:npm/lodash@4.17.21");
546 }
547
548 #[test]
549 fn test_npm_purl_unscoped_without_version() {
550 let purl = npm_purl("lodash", None).unwrap();
551 assert_eq!(purl, "pkg:npm/lodash");
552 }
553
554 #[test]
555 fn test_npm_purl_scoped_slash_not_encoded() {
556 let purl = npm_purl("@types/node", Some("18.0.0")).unwrap();
557 assert!(purl.contains("/%40types/node"));
558 assert!(!purl.contains("%2F"));
559 }
560
561 #[test]
562 fn test_parse_sri_sha512() {
563 let (algo, hash) = parse_sri("sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==").unwrap();
564 assert_eq!(algo, "sha512");
565 assert_eq!(hash.len(), 128);
566 }
567
568 #[test]
569 fn test_parse_sri_sha1() {
570 let (algo, hash) = parse_sri("sha1-w7M6te42DYbg5ijwRorn7yfWVN8=").unwrap();
571 assert_eq!(algo, "sha1");
572 assert_eq!(hash.len(), 40);
573 }
574
575 #[test]
576 fn test_parse_sri_sha256() {
577 let (algo, hash) =
578 parse_sri("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap();
579 assert_eq!(algo, "sha256");
580 assert_eq!(hash.len(), 64);
581 }
582
583 #[test]
584 fn test_parse_sri_invalid_format() {
585 assert!(parse_sri("invalid").is_none());
586 assert!(parse_sri("sha512").is_none());
587 assert!(parse_sri("").is_none());
588 }
589
590 #[test]
591 fn test_parse_sri_invalid_base64() {
592 assert!(parse_sri("sha512-!!!invalid!!!").is_none());
593 }
594
595 #[test]
596 fn test_split_name_email_full_format() {
597 let (name, email) = split_name_email("John Doe <john@example.com>");
598 assert_eq!(name, Some("John Doe".to_string()));
599 assert_eq!(email, Some("john@example.com".to_string()));
600 }
601
602 #[test]
603 fn test_split_name_email_name_only() {
604 let (name, email) = split_name_email("John Doe");
605 assert_eq!(name, Some("John Doe".to_string()));
606 assert_eq!(email, None);
607 }
608
609 #[test]
610 fn test_split_name_email_email_only_plain() {
611 let (name, email) = split_name_email("john@example.com");
612 assert_eq!(name, Some("john@example.com".to_string()));
613 assert_eq!(email, None);
614 }
615
616 #[test]
617 fn test_split_name_email_email_only_brackets() {
618 let (name, email) = split_name_email("<john@example.com>");
619 assert_eq!(name, None);
620 assert_eq!(email, Some("john@example.com".to_string()));
621 }
622
623 #[test]
624 fn test_split_name_email_whitespace_trimming() {
625 let (name, email) = split_name_email(" John Doe < john@example.com > ");
626 assert_eq!(name, Some("John Doe".to_string()));
627 assert_eq!(email, Some(" john@example.com ".to_string()));
628 }
629
630 #[test]
631 fn test_split_name_email_empty_string() {
632 let (name, email) = split_name_email("");
633 assert_eq!(name, Some("".to_string()));
634 assert_eq!(email, None);
635 }
636
637 #[test]
638 fn test_split_name_email_whitespace_only() {
639 let (name, email) = split_name_email(" ");
640 assert_eq!(name, Some("".to_string()));
641 assert_eq!(email, None);
642 }
643
644 #[test]
645 fn test_split_name_email_invalid_bracket_order() {
646 let (name, email) = split_name_email("John >email< Doe");
647 assert_eq!(name, Some("John >email< Doe".to_string()));
648 assert_eq!(email, None);
649 }
650
651 #[test]
652 fn test_split_name_email_missing_close_bracket() {
653 let (name, email) = split_name_email("John Doe <email@example.com");
654 assert_eq!(name, Some("John Doe <email@example.com".to_string()));
655 assert_eq!(email, None);
656 }
657
658 #[test]
659 fn test_split_name_email_missing_open_bracket() {
660 let (name, email) = split_name_email("John Doe email@example.com>");
661 assert_eq!(name, Some("John Doe email@example.com>".to_string()));
662 assert_eq!(email, None);
663 }
664
665 #[test]
666 fn test_read_file_to_string_oversized() {
667 let dir = tempdir().unwrap();
668 let file_path = dir.path().join("big.txt");
669 fs::write(&file_path, "x").unwrap();
670
671 let result = read_file_to_string(&file_path, Some(0));
672 assert!(result.is_err());
673 }
674
675 #[test]
676 fn test_read_file_to_string_lossy_utf8() {
677 let dir = tempdir().unwrap();
678 let file_path = dir.path().join("bad_utf8.txt");
679 let mut file = File::create(&file_path).unwrap();
680 file.write_all(b"hello\xffworld").unwrap();
681
682 let content = read_file_to_string(&file_path, None).unwrap();
683 assert!(content.contains("hello"));
684 assert!(content.contains("world"));
685 }
686
687 #[test]
688 fn test_truncate_field_within_limit() {
689 let s = "short value".to_string();
690 assert_eq!(truncate_field(s.clone()), s);
691 }
692
693 #[test]
694 fn test_truncate_field_exceeds_limit() {
695 let long = "x".repeat(MAX_FIELD_LENGTH + 100);
696 let truncated = truncate_field(long);
697 assert!(truncated.len() <= MAX_FIELD_LENGTH);
698 }
699
700 #[test]
701 fn test_capped_iteration_limit_under_cap() {
702 assert_eq!(capped_iteration_limit(0, "test"), 0);
703 assert_eq!(capped_iteration_limit(10, "test"), 10);
704 assert_eq!(
705 capped_iteration_limit(MAX_ITERATION_COUNT, "test"),
706 MAX_ITERATION_COUNT
707 );
708 }
709
710 #[test]
711 fn test_capped_iteration_limit_truncates() {
712 assert_eq!(
713 capped_iteration_limit(MAX_ITERATION_COUNT + 1, "test"),
714 MAX_ITERATION_COUNT
715 );
716 assert_eq!(
717 capped_iteration_limit(MAX_ITERATION_COUNT * 2, "test"),
718 MAX_ITERATION_COUNT
719 );
720 }
721
722 #[test]
723 fn test_capped_iteration_limit_warns_only_when_truncating() {
724 use crate::models::DiagnosticSeverity;
725 use crate::parsers::capture_parser_diagnostics;
726 use std::path::Path;
727
728 let quiet = capture_parser_diagnostics(
730 || {
731 let _ = capped_iteration_limit(10, "under-cap context");
732 Vec::new()
733 },
734 "test",
735 Path::new("test"),
736 None,
737 );
738 assert!(
739 quiet.scan_diagnostics.is_empty(),
740 "expected no diagnostic under the cap, got: {:?}",
741 quiet.scan_diagnostics
742 );
743
744 let noisy = capture_parser_diagnostics(
746 || {
747 let _ = capped_iteration_limit(MAX_ITERATION_COUNT + 7, "over-cap context");
748 Vec::new()
749 },
750 "test",
751 Path::new("test"),
752 None,
753 );
754 assert!(
755 noisy.scan_diagnostics.iter().any(|diagnostic| {
756 diagnostic.severity == DiagnosticSeverity::Warning
757 && diagnostic.message.contains("over-cap context")
758 && diagnostic.message.contains("MAX_ITERATION_COUNT")
759 }),
760 "expected a truncation warning naming the context, got: {:?}",
761 noisy.scan_diagnostics
762 );
763 }
764
765 #[test]
766 fn test_capped_iter_under_cap_is_quiet_and_complete() {
767 use crate::parsers::capture_parser_diagnostics;
768 use std::path::Path;
769
770 let result = capture_parser_diagnostics(
771 || {
772 let collected: Vec<usize> = (0..10).capped("under-cap iter").collect();
773 assert_eq!(collected, (0..10).collect::<Vec<_>>());
774 Vec::new()
775 },
776 "test",
777 Path::new("test"),
778 None,
779 );
780 assert!(
781 result.scan_diagnostics.is_empty(),
782 "expected no diagnostic under the cap, got: {:?}",
783 result.scan_diagnostics
784 );
785 }
786
787 #[test]
788 fn test_capped_iter_truncates_and_warns() {
789 use crate::models::DiagnosticSeverity;
790 use crate::parsers::capture_parser_diagnostics;
791 use std::path::Path;
792
793 let result = capture_parser_diagnostics(
794 || {
795 let count = (0..MAX_ITERATION_COUNT + 5).capped("over-cap iter").count();
796 assert_eq!(count, MAX_ITERATION_COUNT);
797 Vec::new()
798 },
799 "test",
800 Path::new("test"),
801 None,
802 );
803 assert!(
804 result.scan_diagnostics.iter().any(|diagnostic| {
805 diagnostic.severity == DiagnosticSeverity::Warning
806 && diagnostic.message.contains("over-cap iter")
807 && diagnostic.message.contains("MAX_ITERATION_COUNT")
808 }),
809 "expected a truncation warning naming the context, got: {:?}",
810 result.scan_diagnostics
811 );
812 }
813}