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