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
16struct 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 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 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 fn parse_source_line(
73 &mut self,
74 trimmed: &str,
75 indent: usize,
76 specs: &mut Vec<GemSpec>,
77 source_index: usize,
78 ) {
79 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 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 if indent == 6
112 && let Some(spec) = &mut self.current_spec
113 {
114 spec.dependencies.push(parse_gem_dependency(trimmed));
115 }
116 }
117}
118
119pub 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 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 if indent == 0 {
143 state.flush_spec(&mut specs);
144 state.finalize_source(§ion, &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 state.flush_spec(&mut specs);
190 state.finalize_source(§ion, &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
206fn count_indent(line: &str) -> usize {
208 line.len() - line.trim_start().len()
209}
210
211fn parse_gem_spec_line(trimmed: &str, source_index: usize) -> Option<GemSpec> {
213 let (name, rest) = trimmed.split_once(' ')?;
214 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
228fn 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
236fn 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
260fn 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 #[test]
300 fn parse_secure_lockfile() {
301 let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
302 let lockfile = parse(input).unwrap();
303
304 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 assert_eq!(lockfile.platforms, vec!["ruby", "x86_64-linux"]);
315
316 assert_eq!(lockfile.bundled_with, Some("2.3.6".to_string()));
318
319 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 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 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); }
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 let unique_names: std::collections::HashSet<&str> =
370 lockfile.specs.iter().map(|s| s.name.as_str()).collect();
371
372 assert!(unique_names.len() >= 30);
381 }
382
383 #[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 assert_eq!(lockfile.sources.len(), 2);
392
393 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}