1use super::*;
2
3#[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
16pub 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 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 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 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 if indent == 0 {
55 if let Some(spec) = current_spec.take() {
57 specs.push(spec);
58 }
59 finalize_source(
61 §ion,
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 §ion,
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 if let Some(spec) = current_spec.take() {
125 specs.push(spec);
126 }
127 finalize_source(
128 §ion,
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
150fn count_indent(line: &str) -> usize {
152 line.len() - line.trim_start().len()
153}
154
155fn 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#[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 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 if indent == 4 {
226 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 if indent == 6
239 && let Some(spec) = current_spec
240 {
241 spec.dependencies.push(parse_gem_dependency(trimmed));
242 }
243}
244
245fn parse_gem_spec_line(trimmed: &str, source_index: usize) -> Option<GemSpec> {
247 let (name, rest) = trimmed.split_once(' ')?;
248 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
262fn parse_version_platform(input: &str) -> (String, Option<String>) {
268 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 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
322fn 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
346fn 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 #[test]
386 fn parse_secure_lockfile() {
387 let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
388 let lockfile = parse(input).unwrap();
389
390 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 assert_eq!(lockfile.platforms, vec!["ruby", "x86_64-linux"]);
401
402 assert_eq!(lockfile.bundled_with, Some("2.3.6".to_string()));
404
405 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 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 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); }
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 let unique_names: std::collections::HashSet<&str> =
456 lockfile.specs.iter().map(|s| s.name.as_str()).collect();
457
458 assert!(unique_names.len() >= 30);
467 }
468
469 #[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 assert_eq!(lockfile.sources.len(), 2);
478
479 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}