Skip to main content

provenant/parsers/
ruby.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Ruby/RubyGems package manifests.
7//!
8//! Extracts package metadata, dependencies, and platform information from
9//! Gemfile and Gemfile.lock files used by Ruby/Bundler projects.
10//!
11//! # Supported Formats
12//! - Gemfile (manifest with Ruby DSL)
13//! - Gemfile.lock (lockfile with state machine sections)
14//! - *.gemspec (gem specification files)
15//! - *.gem (gem archive packages)
16//! - metadata.gz-extract (pre-extracted gem metadata)
17//!
18//! # Key Features
19//! - State machine parsing for Gemfile.lock sections (GEM, GIT, PATH, SVN, PLATFORMS, BUNDLED WITH, DEPENDENCIES)
20//! - Regex-based Ruby DSL parsing for Gemfile
21//! - Dependency group handling (:development, :test, etc.)
22//! - Platform-specific gem support
23//! - Pessimistic version operator (~>) support
24//! - Bug Fix #1: Strip .freeze suffix from strings
25//! - Bug Fix #4: Correct dependency scope mapping (:runtime → None, :development → "development")
26//!
27//! # Implementation Notes
28//! - Uses regex for pattern matching (not full Ruby AST)
29//! - Graceful error handling: logs warnings and returns default on parse failure
30//! - PURL type: "gem"
31
32use 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
52// =============================================================================
53// Bug Fix #1: Strip .freeze suffix from strings
54// =============================================================================
55
56/// Strips the `.freeze` suffix from Ruby frozen string literals.
57///
58/// In Ruby, `.freeze` makes a string immutable. We need to remove this suffix
59/// when parsing gem names and versions from Gemfile.
60///
61/// For example, `"name".freeze` becomes `"name"` and `'1.0.0'.freeze`
62/// becomes `'1.0.0'`.
63pub 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
72// =============================================================================
73// Gemfile Parser (Ruby DSL)
74// =============================================================================
75
76/// Ruby Gemfile parser for manifest files.
77///
78/// Parses Ruby DSL syntax to extract gem declarations, dependency groups,
79/// platform-specific gems, and version constraints.
80pub 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
120/// Parses Gemfile content and extracts dependencies with groups.
121fn 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    // Regex patterns for Gemfile parsing
128    // gem "name", "version", options...
129    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    // group :name do ... end
140    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    // Parse symbols like :development, :test
173    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        // Skip comments and empty lines
185        if trimmed.is_empty() || trimmed.starts_with('#') {
186            continue;
187        }
188
189        // Check for group start
190        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        // Check for group end
223        if group_end_regex.is_match(trimmed) {
224            block_stack.pop();
225            continue;
226        }
227
228        // Parse gem declaration
229        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            // Collect version constraints
236            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                // Check if it looks like a version constraint
243                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            // Determine scope based on current group
257            // Bug Fix #4: :runtime → None, :development → "development"
258            let (scope, is_runtime, is_optional) = if current_groups.is_empty() {
259                // No group = runtime dependency
260                (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                // Other groups (e.g., :production)
267                let group = current_groups.first().cloned();
268                (group, true, false)
269            };
270
271            // Create PURL
272            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
397/// Checks if a string looks like a version constraint.
398fn 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
407// =============================================================================
408// Gemfile.lock Parser (State Machine)
409// =============================================================================
410
411/// Ruby Gemfile.lock parser for lockfiles.
412///
413/// Uses a state machine to parse sections: GEM, GIT, PATH, SVN,
414/// PLATFORMS, BUNDLED WITH, DEPENDENCIES.
415pub 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/// Parse state for Gemfile.lock state machine.
456#[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/// Parsed gem information from Gemfile.lock.
470///
471/// All fields are actively used:
472/// - `gem_type`, `remote`, `revision`, `ref_field`, `branch`, `tag`: Stored in extra_data for GIT/PATH/SVN sources
473/// - `name`, `version`, `platform`, `pinned`: Used for dependency PURL and metadata generation
474/// - `requirements`: Stored as extracted_requirement for version constraints
475#[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
514/// Parses Gemfile.lock content using a state machine.
515fn 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    // DEPS pattern: 2 spaces at line start
526    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    // SPEC_DEPS pattern: 4 spaces at line start
535    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    // OPTIONS pattern: key: value
544    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    // VERSION pattern for BUNDLED WITH
553    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        // Empty line resets state
565        if trimmed.is_empty() {
566            current_options.clear();
567            continue;
568        }
569
570        // Section headers (no leading whitespace) and sub-section headers
571        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        // Check for "  specs:" sub-section header (2-space indent) within
616        // GEM/GIT/PATH/SVN sections. This must be checked separately because
617        // the leading whitespace is preserved by trim_end().
618        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        // Process based on current state
629        match state {
630            ParseState::Gem | ParseState::Git | ParseState::Path | ParseState::Svn => {
631                // Parse options (remote:, revision:, ref:, branch:, tag:)
632                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                // Parse gem specs (4 spaces indent)
643                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                    // Parse version and platform
648                    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                // Parse platform entries (2 spaces indent)
670                let platform = trimmed.trim();
671                if !platform.is_empty() {
672                    platforms.push(platform.to_string());
673                }
674            }
675            ParseState::BundledWith => {
676                // Parse bundler version
677                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                // Parse direct dependencies (2 spaces indent)
683                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                        // Update gem info if exists, or create new
690                        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    // Build extra_data
784    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
868/// Parses version and platform from a combined string.
869/// Examples: "2.6.3" -> ("2.6.3", None), "2.6.3-java" -> ("2.6.3", Some("java"))
870fn 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
883/// Creates a gem PURL.
884fn 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
984/// Returns a default PackageData with gem-specific settings.
985fn 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
1000// =============================================================================
1001// Gemspec Parser (Ruby DSL)
1002// =============================================================================
1003
1004/// Ruby .gemspec file parser.
1005///
1006/// Parses `Gem::Specification.new` blocks using regex-based extraction.
1007/// Handles frozen strings (Bug #1), variable version resolution (Bug #2),
1008/// and RFC 5322 email parsing (Bug #6).
1009pub 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
1081/// Cleans a value extracted from gemspec by stripping quotes, .freeze, %q{}, and brackets.
1082fn 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
1112/// Extracts items from a Ruby array literal like `["a", "b", "c"]`.
1113fn 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
1181/// Bug #2: Resolves variable version references like `CSV::VERSION` or `RAILS_VERSION`.
1182///
1183/// Scans the file content for constant definitions matching the variable name
1184/// and returns the resolved string value.
1185fn 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: &regex::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/// Parses a .gemspec file content and returns PackageData.
1472#[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    // Regex for spec.name = "value" or s.name = "value"
1481    // The spec variable name varies: spec, s, gem, etc.
1482    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    // Extract basic fields
1538    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    // Extract licenses (plural)
1566    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    // Extract authors
1573    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    // Extract emails
1588    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    // Build parties from authors and emails
1603    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    // Extract license statement only - detection happens in separate engine
1703    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    // Prefer description over summary
1713    let final_description = description.or(summary);
1714
1715    // Build PURL
1716    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
1751// =============================================================================
1752// .gem Archive Parser (Wave 3)
1753// =============================================================================
1754
1755const MAX_ARCHIVE_SIZE: u64 = 100 * 1024 * 1024; // 100MB
1756const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50MB per file
1757const MAX_COMPRESSION_RATIO: f64 = 100.0; // 100:1 ratio
1758
1759/// Parser for .gem archive files.
1760///
1761/// Extracts metadata from Ruby .gem packages, which are tar archives
1762/// containing a gzip-compressed YAML metadata file (`metadata.gz`).
1763///
1764/// Includes safety checks against zip bombs and oversized archives.
1765pub 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    // Ruby YAML tagged types need to be handled:
1891    // --- !ruby/object:Gem::Specification
1892    // We strip Ruby-specific YAML tags since yaml_serde can't handle them
1893    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    // Licenses
1913    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    // Extract license statement only - detection happens in separate engine
1924    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    // Authors
1934    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    // Build parties
1960    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    // Dependencies
1991    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
2090/// Strips Ruby-specific YAML tags that yaml_serde cannot handle.
2091fn 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        // Extract version requirements from the nested structure
2124        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                            // ">= 0" means "any version" - skip
2140                            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
2182// =============================================================================
2183// Gem Metadata Extracted Parser (metadata.gz-extract files)
2184// =============================================================================
2185
2186pub 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}