Skip to main content

gem_audit/lockfile/
parser.rs

1use super::*;
2
3/// The current section being parsed.
4#[derive(Debug, Clone, PartialEq, Eq)]
5enum Section {
6    None,
7    Git,
8    Gem,
9    Path,
10    Platforms,
11    Dependencies,
12    RubyVersion,
13    BundledWith,
14}
15
16/// Mutable state accumulated while parsing a source section (GEM/GIT/PATH).
17struct SourceState {
18    remote: Option<String>,
19    revision: Option<String>,
20    branch: Option<String>,
21    tag: Option<String>,
22    in_specs: bool,
23    current_spec: Option<GemSpec>,
24}
25
26impl SourceState {
27    fn new() -> Self {
28        Self {
29            remote: None,
30            revision: None,
31            branch: None,
32            tag: None,
33            in_specs: false,
34            current_spec: None,
35        }
36    }
37
38    /// Finalize the current source and push it to `sources`.
39    fn finalize_source(&mut self, section: &Section, sources: &mut Vec<Source>) {
40        if let Some(remote) = self.remote.take() {
41            match section {
42                Section::Gem => {
43                    sources.push(Source::Rubygems(RubygemsSource { remote }));
44                }
45                Section::Git => {
46                    sources.push(Source::Git(GitSource {
47                        remote,
48                        revision: self.revision.take(),
49                        branch: self.branch.take(),
50                        tag: self.tag.take(),
51                    }));
52                }
53                Section::Path => {
54                    sources.push(Source::Path(PathSource { remote }));
55                }
56                _ => {}
57            }
58        }
59        self.revision = None;
60        self.branch = None;
61        self.tag = None;
62    }
63
64    /// Flush the current in-progress spec to `specs`.
65    fn flush_spec(&mut self, specs: &mut Vec<GemSpec>) {
66        if let Some(spec) = self.current_spec.take() {
67            specs.push(spec);
68        }
69    }
70
71    /// Parse a line inside a GEM/GIT/PATH section.
72    fn parse_source_line(
73        &mut self,
74        trimmed: &str,
75        indent: usize,
76        specs: &mut Vec<GemSpec>,
77        source_index: usize,
78    ) {
79        // Indent 2: attributes (remote:, revision:, specs:, branch:, tag:)
80        if indent == 2 {
81            if let Some(value) = trimmed.strip_prefix("remote:") {
82                self.remote = Some(value.trim().to_string());
83                self.in_specs = false;
84            } else if let Some(value) = trimmed.strip_prefix("revision:") {
85                self.revision = Some(value.trim().to_string());
86            } else if let Some(value) = trimmed.strip_prefix("branch:") {
87                self.branch = Some(value.trim().to_string());
88            } else if let Some(value) = trimmed.strip_prefix("tag:") {
89                self.tag = Some(value.trim().to_string());
90            } else if trimmed == "specs:" {
91                self.in_specs = true;
92            }
93            return;
94        }
95
96        if !self.in_specs {
97            return;
98        }
99
100        // Indent 4: gem spec entry — "name (version)" or "name (version-platform)"
101        if indent == 4 {
102            self.flush_spec(specs);
103
104            if let Some(spec) = parse_gem_spec_line(trimmed, source_index) {
105                self.current_spec = Some(spec);
106            }
107            return;
108        }
109
110        // Indent 6: dependency of current spec — "name (constraint)" or "name"
111        if indent == 6
112            && let Some(spec) = &mut self.current_spec
113        {
114            spec.dependencies.push(parse_gem_dependency(trimmed));
115        }
116    }
117}
118
119/// Parse a Gemfile.lock string into a `Lockfile`.
120pub fn parse(input: &str) -> Result<Lockfile, ParseError> {
121    let mut sources: Vec<Source> = Vec::new();
122    let mut specs: Vec<GemSpec> = Vec::new();
123    let mut platforms: Vec<String> = Vec::new();
124    let mut dependencies: Vec<Dependency> = Vec::new();
125    let mut ruby_version: Option<String> = None;
126    let mut bundled_with: Option<String> = None;
127
128    let mut section = Section::None;
129    let mut state = SourceState::new();
130
131    for line in input.lines() {
132        // Empty line — finalize current spec if any
133        if line.trim().is_empty() {
134            state.flush_spec(&mut specs);
135            continue;
136        }
137
138        let indent = count_indent(line);
139        let trimmed = line.trim();
140
141        // Section headers (indent == 0)
142        if indent == 0 {
143            state.flush_spec(&mut specs);
144            state.finalize_source(&section, &mut sources);
145            state.in_specs = false;
146
147            section = match trimmed {
148                "GIT" => Section::Git,
149                "GEM" => Section::Gem,
150                "PATH" => Section::Path,
151                "PLATFORMS" => Section::Platforms,
152                "DEPENDENCIES" => Section::Dependencies,
153                "RUBY VERSION" => Section::RubyVersion,
154                "BUNDLED WITH" => Section::BundledWith,
155                _ => Section::None,
156            };
157            continue;
158        }
159
160        match section {
161            Section::Git | Section::Gem | Section::Path => {
162                state.parse_source_line(trimmed, indent, &mut specs, sources.len());
163            }
164            Section::Platforms => {
165                if indent >= 2 {
166                    platforms.push(trimmed.to_string());
167                }
168            }
169            Section::Dependencies => {
170                if indent >= 2 {
171                    dependencies.push(parse_dependency_line(trimmed));
172                }
173            }
174            Section::RubyVersion => {
175                if indent >= 2 {
176                    ruby_version = Some(trimmed.to_string());
177                }
178            }
179            Section::BundledWith => {
180                if indent >= 2 {
181                    bundled_with = Some(trimmed.to_string());
182                }
183            }
184            Section::None => {}
185        }
186    }
187
188    // Finalize remaining state
189    state.flush_spec(&mut specs);
190    state.finalize_source(&section, &mut sources);
191
192    if sources.is_empty() && specs.is_empty() {
193        return Err(ParseError::Empty);
194    }
195
196    Ok(Lockfile {
197        sources,
198        specs,
199        platforms,
200        dependencies,
201        ruby_version,
202        bundled_with,
203    })
204}
205
206/// Count leading spaces in a line.
207fn count_indent(line: &str) -> usize {
208    line.len() - line.trim_start().len()
209}
210
211/// Parse a gem spec line like "actioncable (5.2.8)" or "nokogiri (1.13.10-x86_64-linux)".
212fn parse_gem_spec_line(trimmed: &str, source_index: usize) -> Option<GemSpec> {
213    let (name, rest) = trimmed.split_once(' ')?;
214    // rest should be "(version)" or "(version-platform)"
215    let version_str = rest.strip_prefix('(')?.strip_suffix(')')?;
216
217    let (version, platform) = parse_version_platform(version_str);
218
219    Some(GemSpec {
220        name: name.to_string(),
221        version,
222        platform,
223        dependencies: Vec::new(),
224        source_index,
225    })
226}
227
228/// Split "1.13.10-x86_64-linux" into version "1.13.10" and platform "x86_64-linux".
229///
230/// Delegates to the shared `platform::split_version_platform` function.
231fn parse_version_platform(input: &str) -> (String, Option<String>) {
232    let (version, platform) = super::platform::split_version_platform(input);
233    (version.to_string(), platform.map(String::from))
234}
235
236/// Parse a gem dependency line like "actionpack (= 5.2.8)" or "method_source" or "rack (~> 2.0, >= 2.0.8)".
237fn parse_gem_dependency(trimmed: &str) -> GemDependency {
238    if let Some(paren_start) = trimmed.find('(') {
239        let name = trimmed[..paren_start].trim();
240        let constraint = trimmed[paren_start + 1..]
241            .strip_suffix(')')
242            .unwrap_or(&trimmed[paren_start + 1..])
243            .trim();
244        GemDependency {
245            name: name.to_string(),
246            requirement: if constraint.is_empty() {
247                None
248            } else {
249                Some(constraint.to_string())
250            },
251        }
252    } else {
253        GemDependency {
254            name: trimmed.to_string(),
255            requirement: None,
256        }
257    }
258}
259
260/// Parse a DEPENDENCIES line like "rails (~> 5.2)" or "jquery-rails!" or "activerecord (= 3.2.10)".
261fn parse_dependency_line(trimmed: &str) -> Dependency {
262    let pinned = trimmed.ends_with('!');
263    let trimmed = if pinned {
264        trimmed.strip_suffix('!').unwrap().trim()
265    } else {
266        trimmed
267    };
268
269    if let Some(paren_start) = trimmed.find('(') {
270        let name = trimmed[..paren_start].trim();
271        let constraint = trimmed[paren_start + 1..]
272            .strip_suffix(')')
273            .unwrap_or(&trimmed[paren_start + 1..])
274            .trim();
275        Dependency {
276            name: name.to_string(),
277            requirement: if constraint.is_empty() {
278                None
279            } else {
280                Some(constraint.to_string())
281            },
282            pinned,
283        }
284    } else {
285        Dependency {
286            name: trimmed.to_string(),
287            requirement: None,
288            pinned,
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    // ========== Secure Lockfile ==========
298
299    #[test]
300    fn parse_secure_lockfile() {
301        let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
302        let lockfile = parse(input).unwrap();
303
304        // Should have one GEM source
305        assert_eq!(lockfile.sources.len(), 1);
306        assert_eq!(
307            lockfile.sources[0],
308            Source::Rubygems(RubygemsSource {
309                remote: "https://rubygems.org/".to_string(),
310            })
311        );
312
313        // Check platforms
314        assert_eq!(lockfile.platforms, vec!["ruby", "x86_64-linux"]);
315
316        // Check bundled with
317        assert_eq!(lockfile.bundled_with, Some("2.3.6".to_string()));
318
319        // Check dependencies
320        assert_eq!(lockfile.dependencies.len(), 2);
321        assert_eq!(lockfile.dependencies[0].name, "rails");
322        assert_eq!(
323            lockfile.dependencies[0].requirement,
324            Some("~> 5.2".to_string())
325        );
326        assert!(!lockfile.dependencies[0].pinned);
327    }
328
329    #[test]
330    fn parse_secure_specs() {
331        let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
332        let lockfile = parse(input).unwrap();
333
334        // Check a specific gem
335        let actioncable = lockfile.find_spec("actioncable").unwrap();
336        assert_eq!(actioncable.version, "5.2.8");
337        assert_eq!(actioncable.dependencies.len(), 3);
338        assert_eq!(actioncable.dependencies[0].name, "actionpack");
339        assert_eq!(
340            actioncable.dependencies[0].requirement,
341            Some("= 5.2.8".to_string())
342        );
343
344        // Check nokogiri with platform variant
345        let nokogiri_specs = lockfile.find_specs("nokogiri");
346        assert_eq!(nokogiri_specs.len(), 2);
347
348        let nokogiri_plain = nokogiri_specs
349            .iter()
350            .find(|s| s.platform.is_none())
351            .unwrap();
352        assert_eq!(nokogiri_plain.version, "1.13.10");
353        assert_eq!(nokogiri_plain.dependencies.len(), 2);
354
355        let nokogiri_linux = nokogiri_specs
356            .iter()
357            .find(|s| s.platform.as_deref() == Some("x86_64-linux"))
358            .unwrap();
359        assert_eq!(nokogiri_linux.version, "1.13.10");
360        assert_eq!(nokogiri_linux.dependencies.len(), 1); // only racc
361    }
362
363    #[test]
364    fn parse_secure_gem_count() {
365        let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
366        let lockfile = parse(input).unwrap();
367
368        // Count unique gem names (some may have platform variants)
369        let unique_names: std::collections::HashSet<&str> =
370            lockfile.specs.iter().map(|s| s.name.as_str()).collect();
371
372        // From the file: actioncable, actionmailer, actionpack, actionview,
373        // activejob, activemodel, activerecord, activestorage, activesupport,
374        // arel, builder, concurrent-ruby, crass, erubi, globalid, i18n, loofah,
375        // mail, marcel, method_source, mini_mime, mini_portile2, minitest, nio4r,
376        // nokogiri (x2 with platform), racc, rack, rack-test, rails,
377        // rails-dom-testing, rails-html-sanitizer, railties, rake, sprockets,
378        // sprockets-rails, thor, thread_safe, tzinfo, websocket-driver,
379        // websocket-extensions
380        assert!(unique_names.len() >= 30);
381    }
382
383    // ========== Insecure Sources Lockfile ==========
384
385    #[test]
386    fn parse_insecure_sources_lockfile() {
387        let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
388        let lockfile = parse(input).unwrap();
389
390        // Should have two sources: GIT + GEM
391        assert_eq!(lockfile.sources.len(), 2);
392
393        // First source: GIT
394        match &lockfile.sources[0] {
395            Source::Git(git) => {
396                assert_eq!(git.remote, "git://github.com/rails/jquery-rails.git");
397                assert_eq!(
398                    git.revision,
399                    Some("a8b003d726522cf663611c114d8f0e79abf8d200".to_string())
400                );
401            }
402            other => panic!("expected Git source, got {:?}", other),
403        }
404
405        // Second source: GEM with http (insecure)
406        match &lockfile.sources[1] {
407            Source::Rubygems(gem) => {
408                assert_eq!(gem.remote, "http://rubygems.org/");
409            }
410            other => panic!("expected Rubygems source, got {:?}", other),
411        }
412    }
413
414    #[test]
415    fn parse_insecure_git_source_specs() {
416        let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
417        let lockfile = parse(input).unwrap();
418
419        // jquery-rails comes from GIT source (index 0)
420        let jquery = lockfile.find_spec("jquery-rails").unwrap();
421        assert_eq!(jquery.version, "4.4.0");
422        assert_eq!(jquery.source_index, 0);
423        assert_eq!(jquery.dependencies.len(), 3);
424    }
425
426    #[test]
427    fn parse_insecure_pinned_dependency() {
428        let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
429        let lockfile = parse(input).unwrap();
430
431        let jquery_dep = lockfile
432            .dependencies
433            .iter()
434            .find(|d| d.name == "jquery-rails")
435            .unwrap();
436        assert!(jquery_dep.pinned);
437        assert!(jquery_dep.requirement.is_none());
438
439        let rails_dep = lockfile
440            .dependencies
441            .iter()
442            .find(|d| d.name == "rails")
443            .unwrap();
444        assert!(!rails_dep.pinned);
445        assert!(rails_dep.requirement.is_none());
446    }
447
448    // ========== Unpatched Gems Lockfile ==========
449
450    #[test]
451    fn parse_unpatched_gems_lockfile() {
452        let input = include_str!("../../tests/fixtures/unpatched_gems/Gemfile.lock");
453        let lockfile = parse(input).unwrap();
454
455        assert_eq!(lockfile.sources.len(), 1);
456        assert_eq!(lockfile.bundled_with, Some("2.2.0".to_string()));
457
458        let activerecord = lockfile.find_spec("activerecord").unwrap();
459        assert_eq!(activerecord.version, "3.2.10");
460
461        // DEPENDENCIES section has "activerecord (= 3.2.10)"
462        assert_eq!(lockfile.dependencies.len(), 1);
463        assert_eq!(lockfile.dependencies[0].name, "activerecord");
464        assert_eq!(
465            lockfile.dependencies[0].requirement,
466            Some("= 3.2.10".to_string())
467        );
468    }
469
470    // ========== Version-Platform Parsing ==========
471
472    #[test]
473    fn parse_version_platform_plain() {
474        let (v, p) = parse_version_platform("1.13.10");
475        assert_eq!(v, "1.13.10");
476        assert_eq!(p, None);
477    }
478
479    #[test]
480    fn parse_version_platform_with_linux() {
481        let (v, p) = parse_version_platform("1.13.10-x86_64-linux");
482        assert_eq!(v, "1.13.10");
483        assert_eq!(p, Some("x86_64-linux".to_string()));
484    }
485
486    #[test]
487    fn parse_version_platform_java() {
488        let (v, p) = parse_version_platform("9.2.14.0-java");
489        assert_eq!(v, "9.2.14.0");
490        assert_eq!(p, Some("java".to_string()));
491    }
492
493    #[test]
494    fn parse_version_platform_darwin() {
495        let (v, p) = parse_version_platform("1.13.10-arm64-darwin");
496        assert_eq!(v, "1.13.10");
497        assert_eq!(p, Some("arm64-darwin".to_string()));
498    }
499
500    #[test]
501    fn parse_version_platform_musl() {
502        let (v, p) = parse_version_platform("1.19.1-aarch64-linux-musl");
503        assert_eq!(v, "1.19.1");
504        assert_eq!(p, Some("aarch64-linux-musl".to_string()));
505    }
506
507    #[test]
508    fn parse_version_platform_x86_musl() {
509        let (v, p) = parse_version_platform("1.19.1-x86_64-linux-musl");
510        assert_eq!(v, "1.19.1");
511        assert_eq!(p, Some("x86_64-linux-musl".to_string()));
512    }
513
514    #[test]
515    fn parse_version_platform_gnu() {
516        let (v, p) = parse_version_platform("1.19.1-x86_64-linux-gnu");
517        assert_eq!(v, "1.19.1");
518        assert_eq!(p, Some("x86_64-linux-gnu".to_string()));
519    }
520
521    // ========== Dependency Line Parsing ==========
522
523    #[test]
524    fn parse_dependency_with_constraint() {
525        let dep = parse_dependency_line("rails (~> 5.2)");
526        assert_eq!(dep.name, "rails");
527        assert_eq!(dep.requirement, Some("~> 5.2".to_string()));
528        assert!(!dep.pinned);
529    }
530
531    #[test]
532    fn parse_dependency_pinned() {
533        let dep = parse_dependency_line("jquery-rails!");
534        assert_eq!(dep.name, "jquery-rails");
535        assert!(dep.requirement.is_none());
536        assert!(dep.pinned);
537    }
538
539    #[test]
540    fn parse_dependency_plain() {
541        let dep = parse_dependency_line("rails");
542        assert_eq!(dep.name, "rails");
543        assert!(dep.requirement.is_none());
544        assert!(!dep.pinned);
545    }
546
547    // ========== Gem Dependency Parsing ==========
548
549    #[test]
550    fn parse_gem_dep_with_constraint() {
551        let dep = parse_gem_dependency("actionpack (= 5.2.8)");
552        assert_eq!(dep.name, "actionpack");
553        assert_eq!(dep.requirement, Some("= 5.2.8".to_string()));
554    }
555
556    #[test]
557    fn parse_gem_dep_compound_constraint() {
558        let dep = parse_gem_dependency("rack (~> 2.0, >= 2.0.8)");
559        assert_eq!(dep.name, "rack");
560        assert_eq!(dep.requirement, Some("~> 2.0, >= 2.0.8".to_string()));
561    }
562
563    #[test]
564    fn parse_gem_dep_no_constraint() {
565        let dep = parse_gem_dependency("method_source");
566        assert_eq!(dep.name, "method_source");
567        assert!(dep.requirement.is_none());
568    }
569
570    // ========== Edge Cases ==========
571
572    #[test]
573    fn parse_empty_input() {
574        let result = parse("");
575        assert!(result.is_err());
576    }
577
578    #[test]
579    fn parse_minimal_lockfile() {
580        let input = "\
581GEM
582  remote: https://rubygems.org/
583  specs:
584    rack (2.2.0)
585
586PLATFORMS
587  ruby
588
589DEPENDENCIES
590  rack
591";
592        let lockfile = parse(input).unwrap();
593        assert_eq!(lockfile.specs.len(), 1);
594        assert_eq!(lockfile.specs[0].name, "rack");
595        assert_eq!(lockfile.specs[0].version, "2.2.0");
596        assert_eq!(lockfile.platforms, vec!["ruby"]);
597        assert_eq!(lockfile.dependencies.len(), 1);
598    }
599
600    // ========== PATH Source ==========
601
602    #[test]
603    fn parse_path_source() {
604        let input = "\
605PATH
606  remote: .
607  specs:
608    my_gem (0.1.0)
609
610GEM
611  remote: https://rubygems.org/
612  specs:
613    rack (2.0.0)
614
615PLATFORMS
616  ruby
617
618DEPENDENCIES
619  my_gem!
620  rack
621";
622        let lockfile = parse(input).unwrap();
623        assert_eq!(lockfile.sources.len(), 2);
624        match &lockfile.sources[0] {
625            Source::Path(p) => assert_eq!(p.remote, "."),
626            other => panic!("expected Path source, got {:?}", other),
627        }
628        let my_gem = lockfile.find_spec("my_gem").unwrap();
629        assert_eq!(my_gem.version, "0.1.0");
630        assert_eq!(my_gem.source_index, 0);
631    }
632
633    // ========== GIT with tag ==========
634
635    #[test]
636    fn parse_git_source_with_tag() {
637        let input = "\
638GIT
639  remote: https://github.com/foo/bar.git
640  revision: abc123
641  tag: v1.0.0
642  specs:
643    bar (1.0.0)
644
645GEM
646  remote: https://rubygems.org/
647  specs:
648    rack (2.0.0)
649
650PLATFORMS
651  ruby
652
653DEPENDENCIES
654  bar!
655  rack
656";
657        let lockfile = parse(input).unwrap();
658        match &lockfile.sources[0] {
659            Source::Git(git) => {
660                assert_eq!(git.tag, Some("v1.0.0".to_string()));
661                assert_eq!(git.revision, Some("abc123".to_string()));
662            }
663            other => panic!("expected Git source, got {:?}", other),
664        }
665    }
666
667    // ========== RUBY VERSION section ==========
668
669    #[test]
670    fn parse_ruby_version_section() {
671        let input = "\
672GEM
673  remote: https://rubygems.org/
674  specs:
675    rack (2.0.0)
676
677PLATFORMS
678  ruby
679
680DEPENDENCIES
681  rack
682
683RUBY VERSION
684   ruby 3.0.0p0
685
686BUNDLED WITH
687   2.3.6
688";
689        let lockfile = parse(input).unwrap();
690        assert_eq!(lockfile.ruby_version, Some("ruby 3.0.0p0".to_string()));
691    }
692
693    #[test]
694    fn all_specs_have_valid_source_index() {
695        let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
696        let lockfile = parse(input).unwrap();
697
698        for spec in &lockfile.specs {
699            assert!(
700                spec.source_index < lockfile.sources.len(),
701                "spec {} has source_index {} but only {} sources",
702                spec.name,
703                spec.source_index,
704                lockfile.sources.len()
705            );
706        }
707    }
708}