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