1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
33use crate::parser_warn as warn;
34use crate::parsers::utils::{
35 CappedIterExt, MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string,
36 split_name_email, truncate_field,
37};
38use flate2::read::GzDecoder;
39use packageurl::PackageUrl;
40use regex::Regex;
41use std::collections::HashMap;
42use std::fs::{self, File};
43use std::io::Read;
44use std::path::{Path, PathBuf};
45use tar::Archive;
46
47use super::PackageParser;
48use super::license_normalization::normalize_spdx_declared_license;
49use super::metadata::ParserMetadata;
50
51const PACKAGE_TYPE: PackageType = PackageType::Gem;
52
53pub fn strip_freeze_suffix(s: &str) -> &str {
65 s.trim_end_matches(".freeze")
66}
67
68enum GemfileBlock {
69 Group(Vec<String>),
70 Source(String),
71}
72
73pub struct GemfileParser;
82
83impl PackageParser for GemfileParser {
84 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
85
86 fn metadata() -> Vec<ParserMetadata> {
87 vec![ParserMetadata {
88 description: "Ruby Gemfile manifest",
89 file_patterns: &["**/Gemfile", "**/data.gz-extract/Gemfile"],
90 package_type: "gem",
91 primary_language: "Ruby",
92 documentation_url: Some("https://bundler.io/man/gemfile.5.html"),
93 }]
94 }
95
96 fn extract_packages(path: &Path) -> Vec<PackageData> {
97 let datasource_id = gemfile_datasource_id(path);
98 let content = match read_file_to_string(path, None) {
99 Ok(c) => c,
100 Err(e) => {
101 warn!("Failed to read Gemfile at {:?}: {}", path, e);
102 return vec![default_package_data_with_datasource(datasource_id)];
103 }
104 };
105
106 let mut package_data = parse_gemfile(&content);
107 package_data.datasource_id = Some(datasource_id);
108 vec![package_data]
109 }
110
111 fn is_match(path: &Path) -> bool {
112 path.file_name()
113 .and_then(|n| n.to_str())
114 .is_some_and(|name| name == "Gemfile")
115 || path
116 .to_str()
117 .is_some_and(|p| p.contains("data.gz-extract/") && p.ends_with("/Gemfile"))
118 }
119}
120
121fn parse_gemfile(content: &str) -> PackageData {
123 let mut dependencies = Vec::new();
124 let mut block_stack = Vec::new();
125 let mut default_source = None;
126 let mut sources = Vec::new();
127
128 let gem_regex = match Regex::new(
131 r#"^\s*gem\s+["']([^"']+)["'](?:\.freeze)?(?:\s*,\s*["']([^"']+)["'](?:\.freeze)?)?(?:\s*,\s*["']([^"']+)["'](?:\.freeze)?)?(?:\s*,\s*(.+))?"#,
132 ) {
133 Ok(r) => r,
134 Err(e) => {
135 warn!("Failed to compile gem regex: {}", e);
136 return default_package_data_with_datasource(DatasourceId::Gemfile);
137 }
138 };
139
140 let group_start_regex = match Regex::new(r"^\s*group\s+(.+?)\s+do\s*$") {
142 Ok(r) => r,
143 Err(e) => {
144 warn!("Failed to compile group regex: {}", e);
145 return default_package_data_with_datasource(DatasourceId::Gemfile);
146 }
147 };
148
149 let group_end_regex = match Regex::new(r"^\s*end\s*$") {
150 Ok(r) => r,
151 Err(e) => {
152 warn!("Failed to compile end regex: {}", e);
153 return default_package_data_with_datasource(DatasourceId::Gemfile);
154 }
155 };
156
157 let source_block_start_regex = match Regex::new(r#"^\s*source\s+["']([^"']+)["']\s+do\s*$"#) {
158 Ok(r) => r,
159 Err(e) => {
160 warn!("Failed to compile source block regex: {}", e);
161 return default_package_data_with_datasource(DatasourceId::Gemfile);
162 }
163 };
164
165 let source_regex = match Regex::new(r#"^\s*source\s+["']([^"']+)["']\s*$"#) {
166 Ok(r) => r,
167 Err(e) => {
168 warn!("Failed to compile source regex: {}", e);
169 return default_package_data_with_datasource(DatasourceId::Gemfile);
170 }
171 };
172
173 let symbol_regex = match Regex::new(r":(\w+)") {
175 Ok(r) => r,
176 Err(e) => {
177 warn!("Failed to compile symbol regex: {}", e);
178 return default_package_data_with_datasource(DatasourceId::Gemfile);
179 }
180 };
181
182 for line in content.lines().capped("Gemfile lines") {
183 let trimmed = line.trim();
184
185 if trimmed.is_empty() || trimmed.starts_with('#') {
187 continue;
188 }
189
190 if let Some(caps) = group_start_regex.captures(trimmed) {
192 let groups_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
193 let mut current_groups = Vec::new();
194 for cap in symbol_regex.captures_iter(groups_str) {
195 if let Some(group_name) = cap.get(1) {
196 current_groups.push(group_name.as_str().to_string());
197 }
198 }
199 block_stack.push(GemfileBlock::Group(current_groups));
200 continue;
201 }
202
203 if let Some(caps) = source_block_start_regex.captures(trimmed) {
204 let source = caps
205 .get(1)
206 .map(|m| m.as_str().to_string())
207 .unwrap_or_default();
208 if !source.is_empty() {
209 push_unique_string(&mut sources, source.clone());
210 block_stack.push(GemfileBlock::Source(source));
211 }
212 continue;
213 }
214
215 if let Some(caps) = source_regex.captures(trimmed) {
216 if let Some(source) = caps.get(1).map(|m| m.as_str().to_string()) {
217 push_unique_string(&mut sources, source.clone());
218 default_source = Some(source);
219 }
220 continue;
221 }
222
223 if group_end_regex.is_match(trimmed) {
225 block_stack.pop();
226 continue;
227 }
228
229 if let Some(caps) = gem_regex.captures(trimmed) {
231 let name = strip_freeze_suffix(caps.get(1).map(|m| m.as_str()).unwrap_or(""));
232 if name.is_empty() {
233 continue;
234 }
235
236 let mut version_parts = Vec::new();
238 if let Some(v) = caps.get(2) {
239 version_parts.push(strip_freeze_suffix(v.as_str()).to_string());
240 }
241 if let Some(v) = caps.get(3) {
242 let v_str = strip_freeze_suffix(v.as_str());
243 if looks_like_version_constraint(v_str) {
245 version_parts.push(v_str.to_string());
246 }
247 }
248
249 let extracted_requirement = if version_parts.is_empty() {
250 None
251 } else {
252 Some(version_parts.join(", "))
253 };
254
255 let current_groups = current_group_names(&block_stack);
256
257 let (scope, is_runtime, is_optional) = if current_groups.is_empty() {
260 (None, true, false)
262 } else if current_groups.iter().any(|g| g == "development") {
263 (Some("development".to_string()), false, true)
264 } else if current_groups.iter().any(|g| g == "test") {
265 (Some("test".to_string()), false, true)
266 } else {
267 let group = current_groups.first().cloned();
269 (group, true, false)
270 };
271
272 let purl = create_gem_purl(name, None);
274 let inherited_source = current_source(&block_stack, default_source.as_deref());
275 let extra_data = build_gemfile_dependency_extra_data(
276 caps.get(4).map(|m| m.as_str()),
277 inherited_source.as_deref(),
278 );
279
280 dependencies.push(Dependency {
281 purl,
282 extracted_requirement,
283 scope,
284 is_runtime: Some(is_runtime),
285 is_optional: Some(is_optional),
286 is_pinned: None,
287 is_direct: Some(true),
288 resolved_package: None,
289 extra_data,
290 });
291 }
292 }
293
294 let extra_data = if sources.is_empty() {
295 None
296 } else {
297 Some(HashMap::from([(
298 "sources".to_string(),
299 serde_json::Value::Array(sources.into_iter().map(serde_json::Value::String).collect()),
300 )]))
301 };
302
303 PackageData {
304 package_type: Some(PACKAGE_TYPE),
305 primary_language: Some("Ruby".to_string()),
306 dependencies,
307 extra_data,
308 datasource_id: Some(DatasourceId::Gemfile),
309 ..default_package_data()
310 }
311}
312
313fn current_group_names(block_stack: &[GemfileBlock]) -> Vec<String> {
314 block_stack
315 .iter()
316 .rev()
317 .find_map(|block| match block {
318 GemfileBlock::Group(groups) => Some(groups.clone()),
319 GemfileBlock::Source(_) => None,
320 })
321 .unwrap_or_default()
322}
323
324fn current_source(block_stack: &[GemfileBlock], default_source: Option<&str>) -> Option<String> {
325 block_stack
326 .iter()
327 .rev()
328 .find_map(|block| match block {
329 GemfileBlock::Source(source) => Some(source.clone()),
330 GemfileBlock::Group(_) => None,
331 })
332 .or_else(|| default_source.map(str::to_string))
333}
334
335fn push_unique_string(values: &mut Vec<String>, value: String) {
336 if !values.contains(&value) {
337 values.push(value);
338 }
339}
340
341fn build_gemfile_dependency_extra_data(
342 options: Option<&str>,
343 inherited_source: Option<&str>,
344) -> Option<HashMap<String, serde_json::Value>> {
345 let mut extra = HashMap::new();
346 let options = options.unwrap_or("");
347
348 if let Some(git) = extract_gemfile_quoted_option(options, "git") {
349 extra.insert(
350 "source_type".to_string(),
351 serde_json::Value::String("GIT".to_string()),
352 );
353 extra.insert("git".to_string(), serde_json::Value::String(git.clone()));
354 extra.insert("remote".to_string(), serde_json::Value::String(git));
355 }
356
357 if let Some(path) = extract_gemfile_quoted_option(options, "path") {
358 extra.insert(
359 "source_type".to_string(),
360 serde_json::Value::String("PATH".to_string()),
361 );
362 extra.insert("path".to_string(), serde_json::Value::String(path));
363 }
364
365 for key in ["branch", "ref", "tag"] {
366 if let Some(value) = extract_gemfile_quoted_option(options, key) {
367 extra.insert(key.to_string(), serde_json::Value::String(value));
368 }
369 }
370
371 let direct_source = extract_gemfile_quoted_option(options, "source");
372 if let Some(source) = direct_source {
373 extra.insert("source".to_string(), serde_json::Value::String(source));
374 } else if !extra.contains_key("source_type")
375 && let Some(source) = inherited_source
376 {
377 extra.insert(
378 "source".to_string(),
379 serde_json::Value::String(source.to_string()),
380 );
381 }
382
383 (!extra.is_empty()).then_some(extra)
384}
385
386fn extract_gemfile_quoted_option(options: &str, key: &str) -> Option<String> {
387 if options.is_empty() {
388 return None;
389 }
390
391 let pattern = format!(r#"(?:^|,\s*){}\s*:\s*["']([^"']+)["']"#, regex::escape(key));
392 Regex::new(&pattern)
393 .ok()
394 .and_then(|regex| regex.captures(options))
395 .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()))
396}
397
398fn looks_like_version_constraint(s: &str) -> bool {
400 s.starts_with('~')
401 || s.starts_with('>')
402 || s.starts_with('<')
403 || s.starts_with('=')
404 || s.starts_with('!')
405 || s.chars().next().is_some_and(|c| c.is_ascii_digit())
406}
407
408pub struct GemfileLockParser;
417
418impl PackageParser for GemfileLockParser {
419 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
420
421 fn metadata() -> Vec<ParserMetadata> {
422 vec![ParserMetadata {
423 description: "Ruby Gemfile.lock lockfile",
424 file_patterns: &["**/Gemfile.lock", "**/data.gz-extract/Gemfile.lock"],
425 package_type: "gem",
426 primary_language: "Ruby",
427 documentation_url: Some("https://bundler.io/man/gemfile.5.html"),
428 }]
429 }
430
431 fn extract_packages(path: &Path) -> Vec<PackageData> {
432 let datasource_id = gemfile_lock_datasource_id(path);
433 let content = match read_file_to_string(path, None) {
434 Ok(c) => c,
435 Err(e) => {
436 warn!("Failed to read Gemfile.lock at {:?}: {}", path, e);
437 return vec![default_package_data_with_datasource(datasource_id)];
438 }
439 };
440
441 let mut package_data = parse_gemfile_lock(&content);
442 package_data.datasource_id = Some(datasource_id);
443 vec![package_data]
444 }
445
446 fn is_match(path: &Path) -> bool {
447 path.file_name()
448 .and_then(|n| n.to_str())
449 .is_some_and(|name| name == "Gemfile.lock")
450 || path
451 .to_str()
452 .is_some_and(|p| p.contains("data.gz-extract/") && p.ends_with("/Gemfile.lock"))
453 }
454}
455
456#[derive(Debug, Clone, PartialEq)]
458enum ParseState {
459 None,
460 Gem,
461 Git,
462 Path,
463 Svn,
464 Specs,
465 Platforms,
466 BundledWith,
467 Dependencies,
468}
469
470#[derive(Debug, Clone, Default)]
477struct GemInfo {
478 name: String,
479 version: Option<String>,
480 platform: Option<String>,
481 gem_type: String,
482 remote: Option<String>,
483 revision: Option<String>,
484 ref_field: Option<String>,
485 branch: Option<String>,
486 tag: Option<String>,
487 pinned: bool,
488 requirements: Vec<String>,
489}
490
491fn select_primary_path_gem(gems: &HashMap<String, GemInfo>) -> Option<GemInfo> {
492 let mut path_gems: Vec<&GemInfo> = gems.values().filter(|gem| gem.gem_type == "PATH").collect();
493 path_gems.sort_by(|left, right| {
494 left.remote
495 .as_deref()
496 .cmp(&right.remote.as_deref())
497 .then_with(|| left.name.cmp(&right.name))
498 });
499
500 path_gems
501 .iter()
502 .copied()
503 .find(|gem| gem.pinned && gem.remote.as_deref() == Some("."))
504 .or_else(|| path_gems.iter().copied().find(|gem| gem.pinned))
505 .or_else(|| {
506 path_gems
507 .iter()
508 .copied()
509 .find(|gem| gem.remote.as_deref() == Some("."))
510 })
511 .or_else(|| path_gems.first().copied())
512 .cloned()
513}
514
515fn parse_gemfile_lock(content: &str) -> PackageData {
517 let mut state = ParseState::None;
518 let mut dependencies = Vec::new();
519 let mut gems: HashMap<String, GemInfo> = HashMap::new();
520 let mut platforms: Vec<String> = Vec::new();
521 let mut bundler_version: Option<String> = None;
522 let mut current_gem_type = String::new();
523 let mut current_remote: Option<String> = None;
524 let mut current_options: HashMap<String, String> = HashMap::new();
525
526 let deps_regex = match Regex::new(r"^ {2}([^ \)\(,!:]+)(?: \(([^)]+)\))?(!)?$") {
528 Ok(r) => r,
529 Err(e) => {
530 warn!("Failed to compile deps regex: {}", e);
531 return default_package_data_with_datasource(DatasourceId::GemfileLock);
532 }
533 };
534
535 let spec_deps_regex = match Regex::new(r"^ {4}([^ \)\(,!:]+)(?: \(([^)]+)\))?$") {
537 Ok(r) => r,
538 Err(e) => {
539 warn!("Failed to compile spec_deps regex: {}", e);
540 return default_package_data_with_datasource(DatasourceId::GemfileLock);
541 }
542 };
543
544 let options_regex = match Regex::new(r"^ {2}([a-z]+): (.+)$") {
546 Ok(r) => r,
547 Err(e) => {
548 warn!("Failed to compile options regex: {}", e);
549 return default_package_data_with_datasource(DatasourceId::GemfileLock);
550 }
551 };
552
553 let version_regex = match Regex::new(r"^\s+(\d+(?:\.\d+)+)\s*$") {
555 Ok(r) => r,
556 Err(e) => {
557 warn!("Failed to compile version regex: {}", e);
558 return default_package_data_with_datasource(DatasourceId::GemfileLock);
559 }
560 };
561
562 for line in content.lines().capped("Gemfile.lock lines") {
563 let trimmed = line.trim_end();
564
565 if trimmed.is_empty() {
567 current_options.clear();
568 continue;
569 }
570
571 match trimmed {
573 "GEM" => {
574 state = ParseState::Gem;
575 current_gem_type = "GEM".to_string();
576 current_remote = None;
577 current_options.clear();
578 continue;
579 }
580 "GIT" => {
581 state = ParseState::Git;
582 current_gem_type = "GIT".to_string();
583 current_remote = None;
584 current_options.clear();
585 continue;
586 }
587 "PATH" => {
588 state = ParseState::Path;
589 current_gem_type = "PATH".to_string();
590 current_remote = None;
591 current_options.clear();
592 continue;
593 }
594 "SVN" => {
595 state = ParseState::Svn;
596 current_gem_type = "SVN".to_string();
597 current_remote = None;
598 current_options.clear();
599 continue;
600 }
601 "PLATFORMS" => {
602 state = ParseState::Platforms;
603 continue;
604 }
605 "BUNDLED WITH" => {
606 state = ParseState::BundledWith;
607 continue;
608 }
609 "DEPENDENCIES" => {
610 state = ParseState::Dependencies;
611 continue;
612 }
613 _ => {}
614 }
615
616 if trimmed.trim() == "specs:" {
620 state = match state {
621 ParseState::Gem | ParseState::Git | ParseState::Path | ParseState::Svn => {
622 ParseState::Specs
623 }
624 _ => state,
625 };
626 continue;
627 }
628
629 match state {
631 ParseState::Gem | ParseState::Git | ParseState::Path | ParseState::Svn => {
632 if let Some(caps) = options_regex.captures(line) {
634 let key = caps.get(1).map(|m| m.as_str()).unwrap_or("");
635 let value = caps.get(2).map(|m| m.as_str()).unwrap_or("");
636 current_options.insert(key.to_string(), value.to_string());
637 if key == "remote" {
638 current_remote = Some(value.to_string());
639 }
640 }
641 }
642 ParseState::Specs => {
643 if let Some(caps) = spec_deps_regex.captures(line) {
645 let name = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
646 let version_str = caps.get(2).map(|m| m.as_str()).unwrap_or("");
647
648 let (version, platform) = parse_version_platform(version_str);
650
651 if !name.is_empty() {
652 let gem_info = GemInfo {
653 name: name.clone(),
654 version,
655 platform,
656 gem_type: current_gem_type.clone(),
657 remote: current_remote.clone(),
658 revision: current_options.get("revision").cloned(),
659 ref_field: current_options.get("ref").cloned(),
660 branch: current_options.get("branch").cloned(),
661 tag: current_options.get("tag").cloned(),
662 pinned: false,
663 requirements: Vec::new(),
664 };
665 gems.insert(name, gem_info);
666 }
667 }
668 }
669 ParseState::Platforms => {
670 let platform = trimmed.trim();
672 if !platform.is_empty() {
673 platforms.push(platform.to_string());
674 }
675 }
676 ParseState::BundledWith => {
677 if let Some(caps) = version_regex.captures(line) {
679 bundler_version = caps.get(1).map(|m| m.as_str().to_string());
680 }
681 }
682 ParseState::Dependencies => {
683 if let Some(caps) = deps_regex.captures(line) {
685 let name = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
686 let version_constraint = caps.get(2).map(|m| m.as_str().to_string());
687 let pinned = caps.get(3).is_some();
688
689 if !name.is_empty() {
690 if let Some(gem) = gems.get_mut(&name) {
692 gem.pinned = pinned;
693 if let Some(vc) = &version_constraint {
694 gem.requirements.push(vc.clone());
695 }
696 } else {
697 let gem_info = GemInfo {
698 name: name.clone(),
699 version: None,
700 platform: None,
701 gem_type: "GEM".to_string(),
702 remote: None,
703 revision: None,
704 ref_field: None,
705 branch: None,
706 tag: None,
707 pinned,
708 requirements: version_constraint.into_iter().collect(),
709 };
710 gems.insert(name, gem_info);
711 }
712 }
713 }
714 }
715 ParseState::None => {}
716 }
717 }
718
719 let primary_gem = select_primary_path_gem(&gems);
720
721 let (
722 package_name,
723 package_version,
724 repository_homepage_url,
725 repository_download_url,
726 api_data_url,
727 download_url,
728 ) = if let Some(ref pg) = primary_gem {
729 let urls = get_rubygems_urls(&pg.name, pg.version.as_deref(), pg.platform.as_deref());
730 (
731 Some(pg.name.clone()),
732 pg.version.clone(),
733 urls.0,
734 urls.1,
735 urls.2,
736 urls.3,
737 )
738 } else {
739 (None, None, None, None, None, None)
740 };
741
742 for (_, gem) in gems {
743 if let Some(ref pg) = primary_gem
744 && gem.name == pg.name
745 {
746 continue;
747 }
748
749 let version_for_purl = gem.version.as_deref();
750 let purl = create_gem_purl(&gem.name, version_for_purl);
751
752 let extracted_requirement = if !gem.requirements.is_empty() {
753 Some(gem.requirements.join(", "))
754 } else {
755 gem.version.clone()
756 };
757
758 let extra_data = build_gem_source_extra_data(&gem);
759
760 dependencies.push(Dependency {
761 purl,
762 extracted_requirement,
763 scope: Some("dependencies".to_string()),
764 is_runtime: Some(true),
765 is_optional: Some(false),
766 is_pinned: Some(gem.pinned),
767 is_direct: Some(true),
768 resolved_package: None,
769 extra_data,
770 });
771 }
772
773 dependencies.sort_by(|left, right| {
774 left.purl
775 .as_deref()
776 .cmp(&right.purl.as_deref())
777 .then_with(|| {
778 left.extracted_requirement
779 .as_deref()
780 .cmp(&right.extracted_requirement.as_deref())
781 })
782 });
783
784 let mut extra_data = HashMap::new();
786 if !platforms.is_empty() {
787 extra_data.insert(
788 "platforms".to_string(),
789 serde_json::Value::Array(
790 platforms
791 .into_iter()
792 .map(serde_json::Value::String)
793 .collect(),
794 ),
795 );
796 }
797 if let Some(bv) = bundler_version {
798 extra_data.insert("bundler_version".to_string(), serde_json::Value::String(bv));
799 }
800
801 let purl = package_name
802 .as_deref()
803 .map(|n| create_gem_purl(n, package_version.as_deref()))
804 .unwrap_or(None);
805
806 PackageData {
807 package_type: Some(PACKAGE_TYPE),
808 name: package_name,
809 version: package_version,
810 primary_language: Some("Ruby".to_string()),
811 download_url,
812 dependencies,
813 repository_homepage_url,
814 repository_download_url,
815 api_data_url,
816 extra_data: if extra_data.is_empty() {
817 None
818 } else {
819 Some(extra_data)
820 },
821 datasource_id: Some(DatasourceId::GemfileLock),
822 purl,
823 ..default_package_data()
824 }
825}
826
827fn build_gem_source_extra_data(gem: &GemInfo) -> Option<HashMap<String, serde_json::Value>> {
828 if gem.gem_type != "GIT" && gem.gem_type != "PATH" && gem.gem_type != "SVN" {
829 return None;
830 }
831
832 let mut extra = HashMap::new();
833 extra.insert(
834 "source_type".to_string(),
835 serde_json::Value::String(gem.gem_type.clone()),
836 );
837
838 if let Some(ref remote) = gem.remote {
839 extra.insert(
840 "remote".to_string(),
841 serde_json::Value::String(remote.clone()),
842 );
843 }
844 if let Some(ref revision) = gem.revision {
845 extra.insert(
846 "revision".to_string(),
847 serde_json::Value::String(revision.clone()),
848 );
849 }
850 if let Some(ref ref_field) = gem.ref_field {
851 extra.insert(
852 "ref".to_string(),
853 serde_json::Value::String(ref_field.clone()),
854 );
855 }
856 if let Some(ref branch) = gem.branch {
857 extra.insert(
858 "branch".to_string(),
859 serde_json::Value::String(branch.clone()),
860 );
861 }
862 if let Some(ref tag) = gem.tag {
863 extra.insert("tag".to_string(), serde_json::Value::String(tag.clone()));
864 }
865
866 Some(extra)
867}
868
869fn parse_version_platform(s: &str) -> (Option<String>, Option<String>) {
872 if s.is_empty() {
873 return (None, None);
874 }
875 if let Some(idx) = s.find('-') {
876 let version = &s[..idx];
877 let platform = &s[idx + 1..];
878 (Some(version.to_string()), Some(platform.to_string()))
879 } else {
880 (Some(s.to_string()), None)
881 }
882}
883
884fn create_gem_purl(name: &str, version: Option<&str>) -> Option<String> {
886 let mut purl = match PackageUrl::new(PACKAGE_TYPE.as_str(), name) {
887 Ok(p) => p,
888 Err(e) => {
889 warn!("Failed to create PURL for gem '{}': {}", name, e);
890 return None;
891 }
892 };
893
894 if let Some(v) = version
895 && let Err(e) = purl.with_version(v)
896 {
897 warn!("Failed to set version '{}' for gem '{}': {}", v, name, e);
898 }
899
900 Some(purl.to_string())
901}
902
903fn rubygems_homepage_url(name: &str, version: Option<&str>) -> Option<String> {
904 if name.is_empty() {
905 return None;
906 }
907
908 if let Some(v) = version {
909 let v = v.trim().trim_matches('/');
910 Some(format!("https://rubygems.org/gems/{}/versions/{}", name, v))
911 } else {
912 Some(format!("https://rubygems.org/gems/{}", name))
913 }
914}
915
916fn rubygems_download_url(
917 name: &str,
918 version: Option<&str>,
919 platform: Option<&str>,
920) -> Option<String> {
921 if name.is_empty() || version.is_none() {
922 return None;
923 }
924
925 let name = name.trim().trim_matches('/');
926 let version = version?.trim().trim_matches('/');
927
928 let version_plat = if let Some(p) = platform {
929 if p != "ruby" {
930 format!("{}-{}", version, p)
931 } else {
932 version.to_string()
933 }
934 } else {
935 version.to_string()
936 };
937
938 Some(format!(
939 "https://rubygems.org/downloads/{}-{}.gem",
940 name, version_plat
941 ))
942}
943
944fn rubygems_api_url(name: &str, version: Option<&str>) -> Option<String> {
945 if name.is_empty() {
946 return None;
947 }
948
949 if let Some(v) = version {
950 Some(format!(
951 "https://rubygems.org/api/v2/rubygems/{}/versions/{}.json",
952 name, v
953 ))
954 } else {
955 Some(format!(
956 "https://rubygems.org/api/v1/versions/{}.json",
957 name
958 ))
959 }
960}
961
962fn get_rubygems_urls(
963 name: &str,
964 version: Option<&str>,
965 platform: Option<&str>,
966) -> (
967 Option<String>,
968 Option<String>,
969 Option<String>,
970 Option<String>,
971) {
972 let repository_homepage_url = rubygems_homepage_url(name, version);
973 let repository_download_url = rubygems_download_url(name, version, platform);
974 let api_data_url = rubygems_api_url(name, version);
975 let download_url = repository_download_url.clone();
976
977 (
978 repository_homepage_url,
979 repository_download_url,
980 api_data_url,
981 download_url,
982 )
983}
984
985fn default_package_data() -> PackageData {
987 PackageData {
988 package_type: Some(PACKAGE_TYPE),
989 primary_language: Some("Ruby".to_string()),
990 ..Default::default()
991 }
992}
993
994fn default_package_data_with_datasource(datasource_id: DatasourceId) -> PackageData {
995 PackageData {
996 datasource_id: Some(datasource_id),
997 ..default_package_data()
998 }
999}
1000
1001pub struct GemspecParser;
1011
1012impl PackageParser for GemspecParser {
1013 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1014
1015 fn metadata() -> Vec<ParserMetadata> {
1016 vec![ParserMetadata {
1017 description: "Ruby .gemspec manifest",
1018 file_patterns: &[
1019 "**/*.gemspec",
1020 "**/data.gz-extract/*.gemspec",
1021 "**/specifications/*.gemspec",
1022 ],
1023 package_type: "gem",
1024 primary_language: "Ruby",
1025 documentation_url: Some("https://guides.rubygems.org/specification-reference/"),
1026 }]
1027 }
1028
1029 fn extract_packages(path: &Path) -> Vec<PackageData> {
1030 let datasource_id = gemspec_datasource_id(path);
1031 let content = match read_file_to_string(path, None) {
1032 Ok(c) => c,
1033 Err(e) => {
1034 warn!("Failed to read .gemspec at {:?}: {}", path, e);
1035 return vec![default_package_data_with_datasource(datasource_id)];
1036 }
1037 };
1038
1039 let mut package_data = parse_gemspec_with_context(&content, path.parent());
1040 package_data.datasource_id = Some(datasource_id);
1041 vec![package_data]
1042 }
1043
1044 fn is_match(path: &Path) -> bool {
1045 path.extension()
1046 .and_then(|ext| ext.to_str())
1047 .is_some_and(|ext| ext == "gemspec")
1048 }
1049}
1050
1051fn normalized_ruby_path(path: &Path) -> String {
1052 path.to_string_lossy().replace('\\', "/")
1053}
1054
1055fn gemfile_datasource_id(path: &Path) -> DatasourceId {
1056 if normalized_ruby_path(path).contains("/data.gz-extract/") {
1057 DatasourceId::GemfileExtracted
1058 } else {
1059 DatasourceId::Gemfile
1060 }
1061}
1062
1063fn gemfile_lock_datasource_id(path: &Path) -> DatasourceId {
1064 if normalized_ruby_path(path).contains("/data.gz-extract/") {
1065 DatasourceId::GemfileLockExtracted
1066 } else {
1067 DatasourceId::GemfileLock
1068 }
1069}
1070
1071fn gemspec_datasource_id(path: &Path) -> DatasourceId {
1072 let normalized = normalized_ruby_path(path);
1073 if normalized.contains("/data.gz-extract/") {
1074 DatasourceId::GemspecExtracted
1075 } else if normalized.contains("/specifications/") {
1076 DatasourceId::GemGemspecInstalledSpecifications
1077 } else {
1078 DatasourceId::Gemspec
1079 }
1080}
1081
1082fn clean_gemspec_value(s: &str) -> String {
1084 let s = strip_freeze_suffix(s).trim();
1085
1086 let s = if let Some(pos) = s.find(" #") {
1087 s[..pos].trim()
1088 } else {
1089 s
1090 };
1091
1092 let s = if let Some(stripped) = s.strip_prefix("%q{") {
1093 stripped.strip_suffix('}').unwrap_or(stripped)
1094 } else if let Some(stripped) = s.strip_prefix("%q<") {
1095 stripped.strip_suffix('>').unwrap_or(stripped)
1096 } else if let Some(stripped) = s.strip_prefix("%q[") {
1097 stripped.strip_suffix(']').unwrap_or(stripped)
1098 } else if let Some(stripped) = s.strip_prefix("%q(") {
1099 stripped.strip_suffix(')').unwrap_or(stripped)
1100 } else {
1101 s
1102 };
1103
1104 let s = s
1105 .trim_start_matches('"')
1106 .trim_end_matches('"')
1107 .trim_start_matches('\'')
1108 .trim_end_matches('\'');
1109 let s = strip_freeze_suffix(s).trim();
1110 s.to_string()
1111}
1112
1113fn extract_ruby_array(s: &str) -> Vec<String> {
1115 let s = strip_freeze_suffix(s.trim());
1116 let s = s.trim_start_matches('[').trim_end_matches(']');
1117 let item_re = match Regex::new(r#"["']([^"']*?)["'](?:\.freeze)?"#) {
1118 Ok(r) => r,
1119 Err(_) => return Vec::new(),
1120 };
1121 item_re
1122 .captures_iter(s)
1123 .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
1124 .collect()
1125}
1126
1127fn extract_all_ruby_values(s: &str) -> Vec<String> {
1128 let value_re = match Regex::new(r#"%q[\{<\[(]([^\}>\])]+)[\}>\])]|["']([^"']+)["']"#) {
1129 Ok(r) => r,
1130 Err(_) => return Vec::new(),
1131 };
1132
1133 value_re
1134 .captures_iter(s)
1135 .filter_map(|caps| caps.get(1).or_else(|| caps.get(2)))
1136 .map(|m| clean_gemspec_value(m.as_str()))
1137 .collect()
1138}
1139
1140fn extract_first_ruby_value(s: &str) -> Option<String> {
1141 extract_all_ruby_values(s).into_iter().next()
1142}
1143
1144fn after_first_argument(args: &str) -> &str {
1145 let mut bracket_depth = 0usize;
1146 let mut paren_depth = 0usize;
1147 let mut in_quote: Option<char> = None;
1148 let chars: Vec<(usize, char)> = args.char_indices().collect();
1149 let mut i = 0;
1150
1151 while i < chars.len() {
1152 let (idx, ch) = chars[i];
1153
1154 if let Some(quote) = in_quote {
1155 if ch == '\\' {
1156 i += 2;
1157 continue;
1158 }
1159 if ch == quote {
1160 in_quote = None;
1161 }
1162 i += 1;
1163 continue;
1164 }
1165
1166 match ch {
1167 '\'' | '"' => in_quote = Some(ch),
1168 '[' | '{' | '<' => bracket_depth += 1,
1169 ']' | '}' | '>' => bracket_depth = bracket_depth.saturating_sub(1),
1170 '(' => paren_depth += 1,
1171 ')' => paren_depth = paren_depth.saturating_sub(1),
1172 ',' if bracket_depth == 0 && paren_depth == 0 => return args[idx + 1..].trim(),
1173 _ => {}
1174 }
1175
1176 i += 1;
1177 }
1178
1179 ""
1180}
1181
1182fn resolve_variable_version(var_name: &str, contexts: &[String]) -> Option<String> {
1187 let var_name = var_name.trim();
1188 if var_name.is_empty() {
1189 return None;
1190 }
1191
1192 for candidate in candidate_constant_names(var_name) {
1193 let escaped = regex::escape(&candidate);
1194 let pattern = format!(r#"(?m)^\s*{}\s*=\s*(.+)$"#, escaped);
1195 let Ok(re) = Regex::new(&pattern) else {
1196 continue;
1197 };
1198
1199 for context in contexts {
1200 if let Some(caps) = re.captures(context)
1201 && let Some(expression) = caps.get(1)
1202 && let Some(resolved) =
1203 resolve_scalar_expression(expression.as_str(), None, contexts)
1204 {
1205 return Some(resolved);
1206 }
1207 }
1208 }
1209
1210 None
1211}
1212
1213fn resolve_variable_array(var_name: &str, contexts: &[String]) -> Option<Vec<String>> {
1214 let var_name = var_name.trim();
1215 if var_name.is_empty() {
1216 return None;
1217 }
1218
1219 for candidate in candidate_constant_names(var_name) {
1220 let escaped = regex::escape(&candidate);
1221 let pattern = format!(r#"(?m)^\s*{}\s*=\s*(\[[^\n]+\])"#, escaped);
1222 let Ok(re) = Regex::new(&pattern) else {
1223 continue;
1224 };
1225
1226 for context in contexts {
1227 if let Some(caps) = re.captures(context)
1228 && let Some(raw) = caps.get(1)
1229 {
1230 let values = extract_ruby_array(raw.as_str());
1231 if !values.is_empty() {
1232 return Some(values);
1233 }
1234 }
1235 }
1236 }
1237
1238 None
1239}
1240
1241fn candidate_constant_names(var_name: &str) -> Vec<String> {
1242 let mut names = vec![var_name.to_string()];
1243 if let Some(last) = var_name.split("::").last()
1244 && last != var_name
1245 {
1246 names.push(last.to_string());
1247 }
1248 names
1249}
1250
1251fn looks_like_local_variable_reference(s: &str) -> bool {
1252 let mut chars = s.chars();
1253 matches!(chars.next(), Some('_' | 'a'..='z'))
1254 && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
1255}
1256
1257fn resolve_ruby_read_root(base_dir: Option<&Path>) -> Option<PathBuf> {
1258 let base_dir = base_dir?;
1259 let current_dir = std::env::current_dir().ok();
1260
1261 current_dir
1262 .and_then(|cwd| {
1263 let canonical_cwd = cwd.canonicalize().ok()?;
1264 let canonical_base = base_dir.canonicalize().ok()?;
1265 canonical_base
1266 .starts_with(&canonical_cwd)
1267 .then_some(canonical_cwd)
1268 })
1269 .or_else(|| base_dir.canonicalize().ok())
1270}
1271
1272fn resolve_ruby_read_path(path: PathBuf, allowed_root: &Path) -> Option<PathBuf> {
1273 let canonical_path = path.canonicalize().ok()?;
1274 canonical_path
1275 .starts_with(allowed_root)
1276 .then_some(canonical_path)
1277}
1278
1279fn resolve_file_read_argument(args: &str, base_dir: Option<&Path>) -> Option<String> {
1280 let base_dir = base_dir?;
1281 let allowed_root = resolve_ruby_read_root(base_dir.into())?;
1282 let relative_path = extract_first_ruby_value(args)?;
1283 if relative_path.is_empty() {
1284 return None;
1285 }
1286
1287 let candidate = Path::new(&relative_path);
1288 let path = if candidate.is_absolute() {
1289 candidate.to_path_buf()
1290 } else {
1291 base_dir.join(candidate)
1292 };
1293
1294 let safe_path = resolve_ruby_read_path(path, &allowed_root)?;
1295
1296 fs::read_to_string(safe_path)
1297 .ok()
1298 .map(|content| content.trim().to_string())
1299 .filter(|content| !content.is_empty())
1300}
1301
1302fn resolve_scalar_expression(
1303 expression: &str,
1304 base_dir: Option<&Path>,
1305 contexts: &[String],
1306) -> Option<String> {
1307 let expression = if let Some(pos) = expression.find(" #") {
1308 expression[..pos].trim()
1309 } else {
1310 expression.trim()
1311 };
1312
1313 let file_read_re = Regex::new(r#"^File\.read\((.+)\)(?:\.strip)?(?:\.freeze)?$"#).ok()?;
1314 if let Some(caps) = file_read_re.captures(expression) {
1315 return caps
1316 .get(1)
1317 .and_then(|m| resolve_file_read_argument(m.as_str(), base_dir));
1318 }
1319
1320 if let Some(joined) = resolve_joined_constant_string(expression, contexts) {
1321 return Some(joined);
1322 }
1323
1324 if let Some(value) = extract_first_ruby_value(expression) {
1325 return Some(interpolate_ruby_constant_string(&value, contexts));
1326 }
1327
1328 let cleaned = clean_gemspec_value(expression);
1329 if looks_like_constant_reference(&cleaned) {
1330 return resolve_variable_version(&cleaned, contexts).or(Some(cleaned));
1331 }
1332
1333 None
1334}
1335
1336fn resolve_joined_constant_string(expression: &str, contexts: &[String]) -> Option<String> {
1337 let expression = strip_freeze_suffix(expression.trim());
1338 if !expression.starts_with('[') {
1339 return None;
1340 }
1341 let join_index = expression.find("].join(")?;
1342 let body = &expression[1..join_index];
1343 let separator_expr = expression[join_index + 7..].strip_suffix(')')?.trim();
1344 let separator = extract_first_ruby_value(separator_expr)?;
1345
1346 let mut parts = Vec::new();
1347 for item in body.split(',').capped("gemspec join expression parts") {
1348 let resolved = resolve_scalar_expression(item.trim(), None, contexts)?;
1349 parts.push(resolved);
1350 }
1351
1352 Some(parts.join(&separator))
1353}
1354
1355fn interpolate_ruby_constant_string(value: &str, contexts: &[String]) -> String {
1356 if !value.contains("#{") {
1357 return value.to_string();
1358 }
1359
1360 let Ok(interpolation_re) = Regex::new(r#"#\{([^}]+)\}"#) else {
1361 return value.to_string();
1362 };
1363 interpolation_re
1364 .replace_all(value, |captures: ®ex::Captures<'_>| {
1365 let reference = captures
1366 .get(1)
1367 .map(|m| m.as_str().trim())
1368 .unwrap_or_default();
1369 resolve_variable_version(reference, contexts).unwrap_or_else(|| {
1370 captures
1371 .get(0)
1372 .map(|value| value.as_str().to_string())
1373 .unwrap_or_default()
1374 })
1375 })
1376 .into_owned()
1377}
1378
1379fn resolve_local_variable_value(
1380 var_name: &str,
1381 content: &str,
1382 base_dir: Option<&Path>,
1383 contexts: &[String],
1384) -> Option<String> {
1385 let escaped = regex::escape(var_name.trim());
1386 let pattern = format!(r#"(?m)^\s*{}\s*=\s*(.+)$"#, escaped);
1387 let re = Regex::new(&pattern).ok()?;
1388
1389 re.captures_iter(content).find_map(|caps| {
1390 caps.get(1)
1391 .and_then(|m| resolve_scalar_expression(m.as_str(), base_dir, contexts))
1392 })
1393}
1394
1395fn resolve_gemspec_scalar_value(
1396 raw_value: &str,
1397 content: &str,
1398 base_dir: Option<&Path>,
1399 contexts: &[String],
1400) -> Option<String> {
1401 let cleaned = truncate_field(clean_gemspec_value(raw_value));
1402 if cleaned.is_empty() {
1403 return None;
1404 }
1405
1406 if looks_like_constant_reference(&cleaned) {
1407 return resolve_variable_version(&cleaned, contexts)
1408 .map(truncate_field)
1409 .or(Some(cleaned));
1410 }
1411
1412 if looks_like_local_variable_reference(&cleaned) {
1413 return resolve_local_variable_value(&cleaned, content, base_dir, contexts)
1414 .map(truncate_field)
1415 .or(Some(cleaned));
1416 }
1417
1418 Some(cleaned)
1419}
1420
1421fn load_required_ruby_contexts(content: &str, base_dir: Option<&Path>) -> Vec<String> {
1422 let mut contexts = vec![content.to_string()];
1423 let Some(base_dir) = base_dir else {
1424 return contexts;
1425 };
1426 let allowed_root = resolve_ruby_read_root(Some(base_dir));
1427
1428 let require_re = match Regex::new(r#"(?m)^\s*require(?:_relative)?\s+["']([^"']+)["']"#) {
1429 Ok(re) => re,
1430 Err(_) => return contexts,
1431 };
1432
1433 for caps in require_re.captures_iter(content) {
1434 let Some(required) = caps.get(1).map(|m| m.as_str()) else {
1435 continue;
1436 };
1437 for candidate in candidate_require_paths(base_dir, required) {
1438 let Some(safe_candidate) = allowed_root
1439 .as_deref()
1440 .and_then(|root| resolve_ruby_read_path(candidate, root))
1441 else {
1442 continue;
1443 };
1444 if let Ok(required_content) = read_file_to_string(&safe_candidate, None) {
1445 contexts.push(required_content);
1446 break;
1447 }
1448 }
1449 }
1450
1451 contexts
1452}
1453
1454fn candidate_require_paths(base_dir: &Path, required: &str) -> Vec<PathBuf> {
1455 let relative = required.replace("::", "/");
1456 let filename = if relative.ends_with(".rb") {
1457 relative
1458 } else {
1459 format!("{}.rb", relative)
1460 };
1461
1462 vec![
1463 base_dir.join(&filename),
1464 base_dir.join("lib").join(&filename),
1465 ]
1466}
1467
1468fn looks_like_constant_reference(s: &str) -> bool {
1469 s.contains("::") || s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1470}
1471
1472#[cfg(test)]
1474fn parse_gemspec(content: &str) -> PackageData {
1475 parse_gemspec_with_context(content, None)
1476}
1477
1478fn parse_gemspec_with_context(content: &str, base_dir: Option<&Path>) -> PackageData {
1479 let contexts = load_required_ruby_contexts(content, base_dir);
1480
1481 let field_re = match Regex::new(
1484 r#"(?m)^\s*\w+\.(name|version|summary|description|homepage|license)\s*=\s*(.+)$"#,
1485 ) {
1486 Ok(r) => r,
1487 Err(e) => {
1488 warn!("Failed to compile gemspec field regex: {}", e);
1489 return default_package_data_with_datasource(DatasourceId::Gemspec);
1490 }
1491 };
1492
1493 let licenses_re = match Regex::new(r#"(?m)^\s*\w+\.licenses\s*=\s*(.+)$"#) {
1494 Ok(r) => r,
1495 Err(e) => {
1496 warn!("Failed to compile licenses regex: {}", e);
1497 return default_package_data_with_datasource(DatasourceId::Gemspec);
1498 }
1499 };
1500
1501 let authors_re = match Regex::new(r#"(?m)^\s*\w+\.(?:authors|author)\s*=\s*(.+)$"#) {
1502 Ok(r) => r,
1503 Err(e) => {
1504 warn!("Failed to compile authors regex: {}", e);
1505 return default_package_data_with_datasource(DatasourceId::Gemspec);
1506 }
1507 };
1508
1509 let email_re = match Regex::new(r#"(?m)^\s*\w+\.email\s*=\s*(.+)$"#) {
1510 Ok(r) => r,
1511 Err(e) => {
1512 warn!("Failed to compile email regex: {}", e);
1513 return default_package_data_with_datasource(DatasourceId::Gemspec);
1514 }
1515 };
1516
1517 let dependency_call_re = match Regex::new(
1518 r#"(?m)^\s*\w+\.(add_(?:development_|runtime_)?dependency)\s*\(?(.+?)\)?\s*$"#,
1519 ) {
1520 Ok(r) => r,
1521 Err(e) => {
1522 warn!("Failed to compile gemspec dependency regex: {}", e);
1523 return default_package_data_with_datasource(DatasourceId::Gemspec);
1524 }
1525 };
1526
1527 let mut name: Option<String> = None;
1528 let mut version: Option<String> = None;
1529 let mut summary: Option<String> = None;
1530 let mut description: Option<String> = None;
1531 let mut homepage: Option<String> = None;
1532 let mut license: Option<String> = None;
1533 let mut licenses: Vec<String> = Vec::new();
1534 let mut authors: Vec<String> = Vec::new();
1535 let mut emails: Vec<String> = Vec::new();
1536 let mut dependencies: Vec<Dependency> = Vec::new();
1537
1538 for caps in field_re.captures_iter(content).capped("gemspec fields") {
1540 let field_name = match caps.get(1) {
1541 Some(m) => m.as_str(),
1542 None => continue,
1543 };
1544 let raw_value = match caps.get(2) {
1545 Some(m) => m.as_str().trim(),
1546 None => continue,
1547 };
1548
1549 match field_name {
1550 "name" => name = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts),
1551 "version" => {
1552 version = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts);
1553 }
1554 "summary" => {
1555 summary = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts)
1556 }
1557 "description" => description = Some(truncate_field(clean_gemspec_value(raw_value))),
1558 "homepage" => {
1559 homepage = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts)
1560 }
1561 "license" => license = Some(truncate_field(clean_gemspec_value(raw_value))),
1562 _ => {}
1563 }
1564 }
1565
1566 for caps in licenses_re
1568 .captures_iter(content)
1569 .capped("gemspec licenses")
1570 {
1571 if let Some(raw) = caps.get(1) {
1572 licenses = extract_ruby_array(raw.as_str());
1573 }
1574 }
1575
1576 for caps in authors_re.captures_iter(content).capped("gemspec authors") {
1578 if let Some(raw) = caps.get(1) {
1579 let raw_str = raw.as_str().trim();
1580 if raw_str.starts_with('[') {
1581 authors = extract_ruby_array(raw_str);
1582 } else if looks_like_constant_reference(raw_str) {
1583 authors = resolve_variable_array(raw_str, &contexts)
1584 .unwrap_or_else(|| vec![clean_gemspec_value(raw_str)]);
1585 } else {
1586 authors.push(clean_gemspec_value(raw_str));
1587 }
1588 }
1589 }
1590
1591 for caps in email_re.captures_iter(content).capped("gemspec emails") {
1593 if let Some(raw) = caps.get(1) {
1594 let raw_str = raw.as_str().trim();
1595 if raw_str.starts_with('[') {
1596 emails = extract_ruby_array(raw_str);
1597 } else if looks_like_constant_reference(raw_str) {
1598 emails = resolve_variable_array(raw_str, &contexts)
1599 .unwrap_or_else(|| vec![clean_gemspec_value(raw_str)]);
1600 } else {
1601 emails.push(clean_gemspec_value(raw_str));
1602 }
1603 }
1604 }
1605
1606 let mut parties: Vec<Party> = Vec::new();
1608
1609 if authors.len() == 1 && emails.len() == 1 {
1610 let email_str = emails.first().map(String::as_str);
1611 let (parsed_email_name, parsed_email) = match email_str {
1612 Some(e) => split_name_email(e),
1613 None => (None, None),
1614 };
1615
1616 parties.push(Party {
1617 r#type: Some(PartyType::Person),
1618 role: Some("author".to_string()),
1619 name: authors.first().cloned().or(parsed_email_name),
1620 email: parsed_email.or_else(|| {
1621 email_str
1622 .filter(|e| e.contains('@') && !e.contains('<'))
1623 .map(|e| e.to_string())
1624 }),
1625 url: None,
1626 organization: None,
1627 organization_url: None,
1628 timezone: None,
1629 });
1630 } else {
1631 for author_name in authors {
1632 parties.push(Party {
1633 r#type: Some(PartyType::Person),
1634 role: Some("author".to_string()),
1635 name: Some(author_name),
1636 email: None,
1637 url: None,
1638 organization: None,
1639 organization_url: None,
1640 timezone: None,
1641 });
1642 }
1643
1644 for email_str in emails {
1645 let (parsed_email_name, parsed_email) = if email_str.contains('<') {
1646 split_name_email(&email_str)
1647 } else {
1648 (None, None)
1649 };
1650 parties.push(Party {
1651 r#type: Some(PartyType::Person),
1652 role: Some("author".to_string()),
1653 name: parsed_email_name,
1654 email: parsed_email.or_else(|| email_str.contains('@').then_some(email_str)),
1655 url: None,
1656 organization: None,
1657 organization_url: None,
1658 timezone: None,
1659 });
1660 }
1661 }
1662
1663 for caps in dependency_call_re
1664 .captures_iter(content)
1665 .capped("gemspec dependency calls")
1666 {
1667 let method = match caps.get(1) {
1668 Some(m) => m.as_str(),
1669 None => continue,
1670 };
1671 let args = match caps.get(2) {
1672 Some(m) => m.as_str(),
1673 None => continue,
1674 };
1675
1676 let Some(dep_name) = extract_first_ruby_value(args).map(truncate_field) else {
1677 continue;
1678 };
1679 let version_parts = extract_all_ruby_values(after_first_argument(args));
1680 let extracted_requirement = if version_parts.is_empty() {
1681 None
1682 } else {
1683 Some(version_parts.join(", "))
1684 };
1685 let purl = create_gem_purl(&dep_name, None);
1686 let is_development = method == "add_development_dependency";
1687 let scope = if is_development {
1688 "development"
1689 } else {
1690 "runtime"
1691 };
1692
1693 dependencies.push(Dependency {
1694 purl,
1695 extracted_requirement,
1696 scope: Some(scope.to_string()),
1697 is_runtime: Some(!is_development),
1698 is_optional: Some(is_development),
1699 is_pinned: None,
1700 is_direct: Some(true),
1701 resolved_package: None,
1702 extra_data: None,
1703 });
1704 }
1705
1706 let extracted_license_statement = if !licenses.is_empty() {
1708 Some(licenses.join(" AND "))
1709 } else {
1710 license
1711 };
1712
1713 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
1714 normalize_spdx_declared_license(extracted_license_statement.as_deref());
1715
1716 let final_description = description.or(summary);
1718
1719 let purl = name
1721 .as_deref()
1722 .map(|n| create_gem_purl(n, version.as_deref()))
1723 .unwrap_or(None);
1724
1725 let (repository_homepage_url, repository_download_url, api_data_url, download_url) =
1726 if let Some(n) = name.as_deref() {
1727 get_rubygems_urls(n, version.as_deref(), None)
1728 } else {
1729 (None, None, None, None)
1730 };
1731
1732 PackageData {
1733 package_type: Some(PACKAGE_TYPE),
1734 name,
1735 version,
1736 primary_language: Some("Ruby".to_string()),
1737 description: final_description,
1738 homepage_url: homepage,
1739 download_url,
1740 declared_license_expression,
1741 declared_license_expression_spdx,
1742 license_detections,
1743 extracted_license_statement,
1744 parties,
1745 dependencies,
1746 repository_homepage_url,
1747 repository_download_url,
1748 api_data_url,
1749 datasource_id: Some(DatasourceId::Gemspec),
1750 purl,
1751 ..default_package_data()
1752 }
1753}
1754
1755const MAX_ARCHIVE_SIZE: u64 = 100 * 1024 * 1024; const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; const MAX_COMPRESSION_RATIO: f64 = 100.0; pub struct GemArchiveParser;
1770
1771impl PackageParser for GemArchiveParser {
1772 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1773
1774 fn metadata() -> Vec<ParserMetadata> {
1775 vec![ParserMetadata {
1776 description: "Ruby .gem archive",
1777 file_patterns: &["**/*.gem"],
1778 package_type: "gem",
1779 primary_language: "Ruby",
1780 documentation_url: Some("https://guides.rubygems.org/specification-reference/"),
1781 }]
1782 }
1783
1784 fn extract_packages(path: &Path) -> Vec<PackageData> {
1785 vec![match extract_gem_archive(path) {
1786 Ok(data) => data,
1787 Err(e) => {
1788 warn!("Failed to extract .gem archive at {:?}: {}", path, e);
1789 default_package_data_with_datasource(DatasourceId::GemArchive)
1790 }
1791 }]
1792 }
1793
1794 fn is_match(path: &Path) -> bool {
1795 path.extension()
1796 .and_then(|ext| ext.to_str())
1797 .is_some_and(|ext| ext == "gem")
1798 }
1799}
1800
1801fn extract_gem_archive(path: &Path) -> Result<PackageData, String> {
1802 let file_metadata =
1803 fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {}", e))?;
1804 let archive_size = file_metadata.len();
1805
1806 if archive_size > MAX_ARCHIVE_SIZE {
1807 return Err(format!(
1808 "Archive too large: {} bytes (limit: {} bytes)",
1809 archive_size, MAX_ARCHIVE_SIZE
1810 ));
1811 }
1812
1813 let file = File::open(path).map_err(|e| format!("Failed to open archive: {}", e))?;
1814 let mut archive = Archive::new(file);
1815
1816 let mut entry_count: usize = 0;
1817 for entry_result in archive
1818 .entries()
1819 .map_err(|e| format!("Failed to read tar entries: {}", e))?
1820 {
1821 entry_count += 1;
1822 if entry_count > MAX_ITERATION_COUNT {
1823 warn!(
1824 "Exceeded max tar entry count ({}) in .gem archive, stopping iteration",
1825 MAX_ITERATION_COUNT
1826 );
1827 break;
1828 }
1829
1830 let entry = entry_result.map_err(|e| format!("Failed to read tar entry: {}", e))?;
1831 let entry_path = entry
1832 .path()
1833 .map_err(|e| format!("Failed to get entry path: {}", e))?;
1834 let entry_str = entry_path.to_string_lossy();
1835 if entry_str.contains("..") {
1836 warn!("Skipping tar entry with path traversal: {}", entry_str);
1837 continue;
1838 }
1839
1840 if entry_path.to_str() == Some("metadata.gz") {
1841 let entry_size = entry.size();
1842 if entry_size > MAX_FILE_SIZE {
1843 return Err(format!(
1844 "metadata.gz too large: {} bytes (limit: {} bytes)",
1845 entry_size, MAX_FILE_SIZE
1846 ));
1847 }
1848
1849 let mut decoder = GzDecoder::new(entry);
1850 let mut content = Vec::new();
1851 let mut limited = std::io::Read::take(&mut decoder, MAX_FILE_SIZE + 1);
1852 limited
1853 .read_to_end(&mut content)
1854 .map_err(|e| format!("Failed to decompress metadata.gz: {}", e))?;
1855
1856 if content.len() > MAX_FILE_SIZE as usize {
1857 return Err(format!(
1858 "Decompressed metadata too large: exceeds {} byte limit",
1859 MAX_FILE_SIZE
1860 ));
1861 }
1862
1863 let content = match String::from_utf8(content) {
1864 Ok(s) => s,
1865 Err(err) => {
1866 let bytes = err.into_bytes();
1867 warn!("Invalid UTF-8 in gem metadata; using lossy conversion");
1868 String::from_utf8_lossy(&bytes).into_owned()
1869 }
1870 };
1871
1872 let uncompressed_size = content.len() as u64;
1873 if entry_size > 0 {
1874 let ratio = uncompressed_size as f64 / entry_size as f64;
1875 if ratio > MAX_COMPRESSION_RATIO {
1876 return Err(format!(
1877 "Suspicious compression ratio: {:.2}:1 (limit: {:.0}:1)",
1878 ratio, MAX_COMPRESSION_RATIO
1879 ));
1880 }
1881 }
1882
1883 return parse_gem_metadata_yaml(&content, DatasourceId::GemArchive);
1884 }
1885 }
1886
1887 Err("metadata.gz not found in .gem archive".to_string())
1888}
1889
1890fn parse_gem_metadata_yaml(
1891 content: &str,
1892 datasource_id: DatasourceId,
1893) -> Result<PackageData, String> {
1894 let cleaned = clean_ruby_yaml_tags(content);
1898
1899 let yaml: yaml_serde::Value =
1900 yaml_serde::from_str(&cleaned).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1901
1902 let name = yaml_string(&yaml, "name").map(truncate_field);
1903 let version = yaml.get("version").and_then(|v| {
1904 if v.is_string() {
1905 v.as_str().map(|s| truncate_field(s.to_string()))
1906 } else {
1907 yaml_string(v, "version").map(truncate_field)
1908 }
1909 });
1910 let description = yaml_string(&yaml, "description")
1911 .or_else(|| yaml_string(&yaml, "summary"))
1912 .map(truncate_field);
1913 let homepage = yaml_string(&yaml, "homepage").map(truncate_field);
1914 let summary = yaml_string(&yaml, "summary").map(truncate_field);
1915
1916 let licenses: Vec<String> = yaml
1918 .get("licenses")
1919 .and_then(|v| v.as_sequence())
1920 .map(|seq| {
1921 seq.iter()
1922 .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
1923 .collect()
1924 })
1925 .unwrap_or_default();
1926
1927 let extracted_license_statement = if !licenses.is_empty() {
1929 Some(licenses.join(" AND "))
1930 } else {
1931 None
1932 };
1933
1934 let (license_expression, license_expression_spdx, license_detections) =
1935 normalize_spdx_declared_license(extracted_license_statement.as_deref());
1936
1937 let authors: Vec<String> = yaml
1939 .get("authors")
1940 .and_then(|v| v.as_sequence())
1941 .map(|seq| {
1942 seq.iter()
1943 .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
1944 .collect()
1945 })
1946 .unwrap_or_default();
1947
1948 let emails: Vec<String> = yaml
1949 .get("email")
1950 .map(|v| {
1951 if let Some(seq) = v.as_sequence() {
1952 seq.iter()
1953 .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
1954 .collect()
1955 } else if let Some(s) = v.as_str() {
1956 vec![truncate_field(s.to_string())]
1957 } else {
1958 Vec::new()
1959 }
1960 })
1961 .unwrap_or_default();
1962
1963 let mut parties: Vec<Party> = Vec::new();
1965 let max_len = authors.len().max(emails.len());
1966 for i in 0..max_len {
1967 let author_name = authors.get(i).map(|s| s.as_str());
1968 let email_str = emails.get(i).map(|s| s.as_str());
1969
1970 let (parsed_email_name, parsed_email) = match email_str {
1971 Some(e) if e.contains('<') => split_name_email(e),
1972 None => (None, None),
1973 _ => (None, None),
1974 };
1975
1976 let party_name = author_name.map(|s| s.to_string()).or(parsed_email_name);
1977
1978 parties.push(Party {
1979 r#type: Some(PartyType::Person),
1980 role: Some("author".to_string()),
1981 name: party_name,
1982 email: parsed_email.or_else(|| {
1983 email_str
1984 .filter(|e| e.contains('@') && !e.contains('<'))
1985 .map(|e| e.to_string())
1986 }),
1987 url: None,
1988 organization: None,
1989 organization_url: None,
1990 timezone: None,
1991 });
1992 }
1993
1994 let dependencies = parse_gem_yaml_dependencies(&yaml);
1996
1997 let metadata = yaml.get("metadata");
1998
1999 let bug_tracking_url = metadata
2000 .and_then(|m| yaml_string(m, "bug_tracking_uri"))
2001 .map(truncate_field);
2002
2003 let code_view_url = metadata
2004 .and_then(|m| yaml_string(m, "source_code_uri"))
2005 .map(truncate_field);
2006
2007 let vcs_url = code_view_url.clone().or_else(|| {
2008 metadata
2009 .and_then(|m| yaml_string(m, "homepage_uri"))
2010 .map(truncate_field)
2011 });
2012
2013 let file_references = metadata
2014 .and_then(|m| m.get("files"))
2015 .and_then(|f| f.as_sequence())
2016 .map(|seq| {
2017 seq.iter()
2018 .filter_map(|v| v.as_str())
2019 .map(|s| crate::models::FileReference {
2020 path: s.to_string(),
2021 size: None,
2022 sha1: None,
2023 md5: None,
2024 sha256: None,
2025 sha512: None,
2026 extra_data: None,
2027 })
2028 .collect::<Vec<_>>()
2029 })
2030 .unwrap_or_default();
2031
2032 let release_date = yaml_string(&yaml, "date").and_then(|d| {
2033 if d.len() >= 10 {
2034 Some(d[..10].to_string())
2035 } else {
2036 None
2037 }
2038 });
2039
2040 let purl = name
2041 .as_deref()
2042 .map(|n| create_gem_purl(n, version.as_deref()))
2043 .unwrap_or(None);
2044
2045 let platform = yaml_string(&yaml, "platform").map(truncate_field);
2046 let (repository_homepage_url, repository_download_url, api_data_url, download_url) =
2047 if let Some(n) = name.as_deref() {
2048 get_rubygems_urls(n, version.as_deref(), platform.as_deref())
2049 } else {
2050 (None, None, None, None)
2051 };
2052
2053 let qualifiers = if let Some(ref p) = platform {
2054 if p != "ruby" {
2055 let mut q = HashMap::new();
2056 q.insert("platform".to_string(), p.clone());
2057 Some(q)
2058 } else {
2059 None
2060 }
2061 } else {
2062 None
2063 };
2064
2065 Ok(PackageData {
2066 package_type: Some(PACKAGE_TYPE),
2067 name,
2068 version,
2069 qualifiers,
2070 primary_language: Some("Ruby".to_string()),
2071 description: description.or(summary),
2072 release_date,
2073 homepage_url: homepage,
2074 download_url,
2075 bug_tracking_url,
2076 code_view_url,
2077 declared_license_expression: license_expression,
2078 declared_license_expression_spdx: license_expression_spdx,
2079 license_detections,
2080 extracted_license_statement,
2081 file_references,
2082 parties,
2083 dependencies,
2084 repository_homepage_url,
2085 repository_download_url,
2086 api_data_url,
2087 datasource_id: Some(datasource_id),
2088 purl,
2089 vcs_url,
2090 ..default_package_data()
2091 })
2092}
2093
2094fn clean_ruby_yaml_tags(content: &str) -> String {
2096 let tag_re = match Regex::new(r"!ruby/\S+") {
2097 Ok(r) => r,
2098 Err(_) => return content.to_string(),
2099 };
2100 tag_re.replace_all(content, "").to_string()
2101}
2102
2103fn yaml_string(yaml: &yaml_serde::Value, key: &str) -> Option<String> {
2104 yaml.get(key)
2105 .and_then(|v| v.as_str())
2106 .filter(|s| !s.is_empty())
2107 .map(|s| s.to_string())
2108}
2109
2110fn parse_gem_yaml_dependencies(yaml: &yaml_serde::Value) -> Vec<Dependency> {
2111 let mut dependencies = Vec::new();
2112
2113 let deps_seq = match yaml.get("dependencies").and_then(|v| v.as_sequence()) {
2114 Some(seq) => seq,
2115 None => return dependencies,
2116 };
2117
2118 let limit = capped_iteration_limit(deps_seq.len(), "gem metadata YAML dependencies");
2119 for dep_value in deps_seq.iter().take(limit) {
2120 let dep_name = match yaml_string(dep_value, "name").map(truncate_field) {
2121 Some(n) => n,
2122 None => continue,
2123 };
2124
2125 let dep_type = yaml_string(dep_value, "type");
2126 let is_development = dep_type.as_deref() == Some(":development");
2127
2128 let requirements = dep_value
2130 .get("requirement")
2131 .or_else(|| dep_value.get("version_requirements"))
2132 .and_then(|req| req.get("requirements"))
2133 .and_then(|reqs| reqs.as_sequence());
2134
2135 let extracted_requirement = requirements.map(|reqs| {
2136 let parts: Vec<String> = reqs
2137 .iter()
2138 .filter_map(|req| {
2139 let seq = req.as_sequence()?;
2140 if seq.len() >= 2 {
2141 let op = seq[0].as_str().unwrap_or("");
2142 let ver = seq[1].get("version").and_then(|v| v.as_str()).unwrap_or("");
2143 if op == ">=" && ver == "0" {
2144 None
2146 } else if op.is_empty() || ver.is_empty() {
2147 None
2148 } else {
2149 Some(format!("{} {}", op, ver))
2150 }
2151 } else {
2152 None
2153 }
2154 })
2155 .collect();
2156 parts.join(", ")
2157 });
2158
2159 let extracted_requirement = extracted_requirement
2160 .filter(|s| !s.is_empty())
2161 .or_else(|| Some(String::new()));
2162
2163 let (scope, is_runtime, is_optional) = if is_development {
2164 (Some("development".to_string()), false, true)
2165 } else {
2166 (Some("runtime".to_string()), true, false)
2167 };
2168
2169 let purl = create_gem_purl(&dep_name, None);
2170
2171 dependencies.push(Dependency {
2172 purl,
2173 extracted_requirement,
2174 scope,
2175 is_runtime: Some(is_runtime),
2176 is_optional: Some(is_optional),
2177 is_pinned: None,
2178 is_direct: Some(true),
2179 resolved_package: None,
2180 extra_data: None,
2181 });
2182 }
2183
2184 dependencies
2185}
2186
2187pub struct GemMetadataExtractedParser;
2192
2193impl PackageParser for GemMetadataExtractedParser {
2194 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
2195
2196 fn metadata() -> Vec<ParserMetadata> {
2197 vec![ParserMetadata {
2198 description: "Ruby gem metadata (extracted)",
2199 file_patterns: &["**/metadata.gz-extract"],
2200 package_type: "gem",
2201 primary_language: "Ruby",
2202 documentation_url: Some("https://guides.rubygems.org/specification-reference/"),
2203 }]
2204 }
2205
2206 fn extract_packages(path: &Path) -> Vec<PackageData> {
2207 vec![match extract_gem_metadata_extracted(path) {
2208 Ok(data) => data,
2209 Err(e) => {
2210 warn!("Failed to extract gem metadata from {:?}: {}", path, e);
2211 default_package_data_with_datasource(DatasourceId::GemArchiveExtracted)
2212 }
2213 }]
2214 }
2215
2216 fn is_match(path: &Path) -> bool {
2217 path.to_str()
2218 .is_some_and(|p| p.contains("metadata.gz-extract"))
2219 }
2220}
2221
2222fn extract_gem_metadata_extracted(path: &Path) -> Result<PackageData, String> {
2223 let content = read_file_to_string(path, None)
2224 .map_err(|e| format!("Failed to read metadata.gz-extract file: {}", e))?;
2225
2226 parse_gem_metadata_yaml(&content, DatasourceId::GemArchiveExtracted)
2227}
2228
2229#[cfg(test)]
2230mod tests {
2231 use super::parse_gemspec;
2232
2233 #[test]
2234 fn test_clean_gemspec_value_handles_unterminated_percent_q() {
2235 assert_eq!(
2236 super::clean_gemspec_value("%q{Arel is a SQL AST manager for Ruby. It"),
2237 "Arel is a SQL AST manager for Ruby. It"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_parse_gemspec_runtime_dependency_scope() {
2243 let content = r#"
2244Gem::Specification.new do |spec|
2245 spec.name = "demo"
2246 spec.version = "1.0.0"
2247 spec.add_runtime_dependency "rack", "~> 3.0"
2248 spec.add_dependency "thor", ">= 1.0"
2249end
2250"#;
2251
2252 let package_data = parse_gemspec(content);
2253 assert_eq!(package_data.dependencies.len(), 2);
2254 assert_eq!(
2255 package_data.dependencies[0].scope,
2256 Some("runtime".to_string())
2257 );
2258 assert_eq!(
2259 package_data.dependencies[0].extracted_requirement,
2260 Some("~> 3.0".to_string())
2261 );
2262 assert_eq!(
2263 package_data.dependencies[1].scope,
2264 Some("runtime".to_string())
2265 );
2266 assert_eq!(
2267 package_data.dependencies[1].extracted_requirement,
2268 Some(">= 1.0".to_string())
2269 );
2270 }
2271}