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 npm_purl(full_name: &str, version: Option<&str>) -> Option<String> {
287 let (namespace, name) = if full_name.starts_with('@') {
288 let parts: Vec<&str> = full_name.splitn(2, '/').collect();
289 if parts.len() == 2 {
290 (Some(parts[0]), parts[1])
291 } else {
292 (None, full_name)
293 }
294 } else {
295 (None, full_name)
296 };
297
298 let mut purl = PackageUrl::new("npm", name).ok()?;
299
300 if let Some(ns) = namespace {
301 purl.with_namespace(ns).ok()?;
302 }
303
304 if let Some(ver) = version {
305 purl.with_version(ver).ok()?;
306 }
307
308 Some(purl.to_string())
309}
310
311pub fn parse_sri(integrity: &str) -> Option<(String, String)> {
317 let parts: Vec<&str> = integrity.splitn(2, '-').collect();
318 if parts.len() != 2 {
319 return None;
320 }
321
322 let algorithm = parts[0];
323 let base64_str = parts[1];
324
325 let bytes = BASE64_STANDARD.decode(base64_str).ok()?;
326
327 let hex_string = bytes
328 .iter()
329 .map(|b| format!("{:02x}", b))
330 .collect::<String>();
331
332 Some((algorithm.to_string(), hex_string))
333}
334
335pub fn split_name_email(s: &str) -> (Option<String>, Option<String>) {
355 if let Some(email_start) = s.find('<')
356 && let Some(email_end) = s.find('>')
357 && email_start < email_end
358 {
359 let name = s[..email_start].trim();
360 let email = &s[email_start + 1..email_end];
361 (
362 if name.is_empty() {
363 None
364 } else {
365 Some(name.to_string())
366 },
367 Some(email.to_string()),
368 )
369 } else {
370 (Some(s.trim().to_string()), None)
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use std::io::Write;
378 use tempfile::tempdir;
379
380 #[test]
381 fn test_recursion_guard_tracks_depth_and_cycles() {
382 let mut guard = RecursionGuard::new();
383
384 assert_eq!(guard.depth(), 0);
385 assert!(!guard.exceeded());
386
387 assert!(!guard.enter("root"));
388 assert_eq!(guard.depth(), 1);
389 assert!(!guard.enter("child"));
390 assert_eq!(guard.depth(), 2);
391
392 assert!(guard.enter("root"));
393 assert_eq!(guard.depth(), 2);
394
395 guard.leave("child");
396 assert_eq!(guard.depth(), 1);
397 guard.leave("root");
398 assert_eq!(guard.depth(), 0);
399 assert!(!guard.exceeded());
400 }
401
402 #[test]
403 fn test_recursion_guard_depth_limit_and_depth_only_mode() {
404 let mut guard = RecursionGuard::<()>::depth_only();
405
406 for _ in 0..MAX_RECURSION_DEPTH {
407 assert!(!guard.descend());
408 }
409
410 assert_eq!(guard.depth(), MAX_RECURSION_DEPTH);
411 assert!(!guard.exceeded());
412
413 assert!(guard.descend());
414 assert_eq!(guard.depth(), MAX_RECURSION_DEPTH + 1);
415 assert!(guard.exceeded());
416
417 guard.ascend();
418 assert_eq!(guard.depth(), MAX_RECURSION_DEPTH);
419 assert!(!guard.exceeded());
420 }
421
422 #[test]
423 fn test_read_file_to_string_success() {
424 let dir = tempdir().unwrap();
425 let file_path = dir.path().join("test.txt");
426 let mut file = File::create(&file_path).unwrap();
427 file.write_all(b"test content").unwrap();
428
429 let content = read_file_to_string(&file_path, None).unwrap();
430 assert_eq!(content, "test content");
431 }
432
433 #[test]
434 fn test_read_file_to_string_nonexistent() {
435 let path = Path::new("/nonexistent/file.txt");
436 let result = read_file_to_string(path, None);
437 assert!(result.is_err());
438 }
439
440 #[test]
441 fn test_read_file_to_string_empty() {
442 let dir = tempdir().unwrap();
443 let file_path = dir.path().join("empty.txt");
444 File::create(&file_path).unwrap();
445
446 let content = read_file_to_string(&file_path, None).unwrap();
447 assert_eq!(content, "");
448 }
449
450 #[test]
451 fn test_npm_purl_scoped_with_version() {
452 let purl = npm_purl("@babel/core", Some("7.0.0")).unwrap();
453 assert_eq!(purl, "pkg:npm/%40babel/core@7.0.0");
454 }
455
456 #[test]
457 fn test_npm_purl_scoped_without_version() {
458 let purl = npm_purl("@babel/core", None).unwrap();
459 assert_eq!(purl, "pkg:npm/%40babel/core");
460 }
461
462 #[test]
463 fn test_npm_purl_unscoped_with_version() {
464 let purl = npm_purl("lodash", Some("4.17.21")).unwrap();
465 assert_eq!(purl, "pkg:npm/lodash@4.17.21");
466 }
467
468 #[test]
469 fn test_npm_purl_unscoped_without_version() {
470 let purl = npm_purl("lodash", None).unwrap();
471 assert_eq!(purl, "pkg:npm/lodash");
472 }
473
474 #[test]
475 fn test_npm_purl_scoped_slash_not_encoded() {
476 let purl = npm_purl("@types/node", Some("18.0.0")).unwrap();
477 assert!(purl.contains("/%40types/node"));
478 assert!(!purl.contains("%2F"));
479 }
480
481 #[test]
482 fn test_parse_sri_sha512() {
483 let (algo, hash) = parse_sri("sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==").unwrap();
484 assert_eq!(algo, "sha512");
485 assert_eq!(hash.len(), 128);
486 }
487
488 #[test]
489 fn test_parse_sri_sha1() {
490 let (algo, hash) = parse_sri("sha1-w7M6te42DYbg5ijwRorn7yfWVN8=").unwrap();
491 assert_eq!(algo, "sha1");
492 assert_eq!(hash.len(), 40);
493 }
494
495 #[test]
496 fn test_parse_sri_sha256() {
497 let (algo, hash) =
498 parse_sri("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap();
499 assert_eq!(algo, "sha256");
500 assert_eq!(hash.len(), 64);
501 }
502
503 #[test]
504 fn test_parse_sri_invalid_format() {
505 assert!(parse_sri("invalid").is_none());
506 assert!(parse_sri("sha512").is_none());
507 assert!(parse_sri("").is_none());
508 }
509
510 #[test]
511 fn test_parse_sri_invalid_base64() {
512 assert!(parse_sri("sha512-!!!invalid!!!").is_none());
513 }
514
515 #[test]
516 fn test_split_name_email_full_format() {
517 let (name, email) = split_name_email("John Doe <john@example.com>");
518 assert_eq!(name, Some("John Doe".to_string()));
519 assert_eq!(email, Some("john@example.com".to_string()));
520 }
521
522 #[test]
523 fn test_split_name_email_name_only() {
524 let (name, email) = split_name_email("John Doe");
525 assert_eq!(name, Some("John Doe".to_string()));
526 assert_eq!(email, None);
527 }
528
529 #[test]
530 fn test_split_name_email_email_only_plain() {
531 let (name, email) = split_name_email("john@example.com");
532 assert_eq!(name, Some("john@example.com".to_string()));
533 assert_eq!(email, None);
534 }
535
536 #[test]
537 fn test_split_name_email_email_only_brackets() {
538 let (name, email) = split_name_email("<john@example.com>");
539 assert_eq!(name, None);
540 assert_eq!(email, Some("john@example.com".to_string()));
541 }
542
543 #[test]
544 fn test_split_name_email_whitespace_trimming() {
545 let (name, email) = split_name_email(" John Doe < john@example.com > ");
546 assert_eq!(name, Some("John Doe".to_string()));
547 assert_eq!(email, Some(" john@example.com ".to_string()));
548 }
549
550 #[test]
551 fn test_split_name_email_empty_string() {
552 let (name, email) = split_name_email("");
553 assert_eq!(name, Some("".to_string()));
554 assert_eq!(email, None);
555 }
556
557 #[test]
558 fn test_split_name_email_whitespace_only() {
559 let (name, email) = split_name_email(" ");
560 assert_eq!(name, Some("".to_string()));
561 assert_eq!(email, None);
562 }
563
564 #[test]
565 fn test_split_name_email_invalid_bracket_order() {
566 let (name, email) = split_name_email("John >email< Doe");
567 assert_eq!(name, Some("John >email< Doe".to_string()));
568 assert_eq!(email, None);
569 }
570
571 #[test]
572 fn test_split_name_email_missing_close_bracket() {
573 let (name, email) = split_name_email("John Doe <email@example.com");
574 assert_eq!(name, Some("John Doe <email@example.com".to_string()));
575 assert_eq!(email, None);
576 }
577
578 #[test]
579 fn test_split_name_email_missing_open_bracket() {
580 let (name, email) = split_name_email("John Doe email@example.com>");
581 assert_eq!(name, Some("John Doe email@example.com>".to_string()));
582 assert_eq!(email, None);
583 }
584
585 #[test]
586 fn test_read_file_to_string_oversized() {
587 let dir = tempdir().unwrap();
588 let file_path = dir.path().join("big.txt");
589 fs::write(&file_path, "x").unwrap();
590
591 let result = read_file_to_string(&file_path, Some(0));
592 assert!(result.is_err());
593 }
594
595 #[test]
596 fn test_read_file_to_string_lossy_utf8() {
597 let dir = tempdir().unwrap();
598 let file_path = dir.path().join("bad_utf8.txt");
599 let mut file = File::create(&file_path).unwrap();
600 file.write_all(b"hello\xffworld").unwrap();
601
602 let content = read_file_to_string(&file_path, None).unwrap();
603 assert!(content.contains("hello"));
604 assert!(content.contains("world"));
605 }
606
607 #[test]
608 fn test_truncate_field_within_limit() {
609 let s = "short value".to_string();
610 assert_eq!(truncate_field(s.clone()), s);
611 }
612
613 #[test]
614 fn test_truncate_field_exceeds_limit() {
615 let long = "x".repeat(MAX_FIELD_LENGTH + 100);
616 let truncated = truncate_field(long);
617 assert!(truncated.len() <= MAX_FIELD_LENGTH);
618 }
619
620 #[test]
621 fn test_capped_iteration_limit_under_cap() {
622 assert_eq!(capped_iteration_limit(0, "test"), 0);
623 assert_eq!(capped_iteration_limit(10, "test"), 10);
624 assert_eq!(
625 capped_iteration_limit(MAX_ITERATION_COUNT, "test"),
626 MAX_ITERATION_COUNT
627 );
628 }
629
630 #[test]
631 fn test_capped_iteration_limit_truncates() {
632 assert_eq!(
633 capped_iteration_limit(MAX_ITERATION_COUNT + 1, "test"),
634 MAX_ITERATION_COUNT
635 );
636 assert_eq!(
637 capped_iteration_limit(MAX_ITERATION_COUNT * 2, "test"),
638 MAX_ITERATION_COUNT
639 );
640 }
641
642 #[test]
643 fn test_capped_iteration_limit_warns_only_when_truncating() {
644 use crate::models::DiagnosticSeverity;
645 use crate::parsers::capture_parser_diagnostics;
646 use std::path::Path;
647
648 let quiet = capture_parser_diagnostics(
650 || {
651 let _ = capped_iteration_limit(10, "under-cap context");
652 Vec::new()
653 },
654 "test",
655 Path::new("test"),
656 None,
657 );
658 assert!(
659 quiet.scan_diagnostics.is_empty(),
660 "expected no diagnostic under the cap, got: {:?}",
661 quiet.scan_diagnostics
662 );
663
664 let noisy = capture_parser_diagnostics(
666 || {
667 let _ = capped_iteration_limit(MAX_ITERATION_COUNT + 7, "over-cap context");
668 Vec::new()
669 },
670 "test",
671 Path::new("test"),
672 None,
673 );
674 assert!(
675 noisy.scan_diagnostics.iter().any(|diagnostic| {
676 diagnostic.severity == DiagnosticSeverity::Warning
677 && diagnostic.message.contains("over-cap context")
678 && diagnostic.message.contains("MAX_ITERATION_COUNT")
679 }),
680 "expected a truncation warning naming the context, got: {:?}",
681 noisy.scan_diagnostics
682 );
683 }
684
685 #[test]
686 fn test_capped_iter_under_cap_is_quiet_and_complete() {
687 use crate::parsers::capture_parser_diagnostics;
688 use std::path::Path;
689
690 let result = capture_parser_diagnostics(
691 || {
692 let collected: Vec<usize> = (0..10).capped("under-cap iter").collect();
693 assert_eq!(collected, (0..10).collect::<Vec<_>>());
694 Vec::new()
695 },
696 "test",
697 Path::new("test"),
698 None,
699 );
700 assert!(
701 result.scan_diagnostics.is_empty(),
702 "expected no diagnostic under the cap, got: {:?}",
703 result.scan_diagnostics
704 );
705 }
706
707 #[test]
708 fn test_capped_iter_truncates_and_warns() {
709 use crate::models::DiagnosticSeverity;
710 use crate::parsers::capture_parser_diagnostics;
711 use std::path::Path;
712
713 let result = capture_parser_diagnostics(
714 || {
715 let count = (0..MAX_ITERATION_COUNT + 5).capped("over-cap iter").count();
716 assert_eq!(count, MAX_ITERATION_COUNT);
717 Vec::new()
718 },
719 "test",
720 Path::new("test"),
721 None,
722 );
723 assert!(
724 result.scan_diagnostics.iter().any(|diagnostic| {
725 diagnostic.severity == DiagnosticSeverity::Warning
726 && diagnostic.message.contains("over-cap iter")
727 && diagnostic.message.contains("MAX_ITERATION_COUNT")
728 }),
729 "expected a truncation warning naming the context, got: {:?}",
730 result.scan_diagnostics
731 );
732 }
733}