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