use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Section {
None,
Git,
Gem,
Path,
Platforms,
Dependencies,
RubyVersion,
BundledWith,
}
pub fn parse(input: &str) -> Result<Lockfile, ParseError> {
let mut sources: Vec<Source> = Vec::new();
let mut specs: Vec<GemSpec> = Vec::new();
let mut platforms: Vec<String> = Vec::new();
let mut dependencies: Vec<Dependency> = Vec::new();
let mut ruby_version: Option<String> = None;
let mut bundled_with: Option<String> = None;
let mut section = Section::None;
let mut in_specs = false;
let mut current_remote: Option<String> = None;
let mut current_revision: Option<String> = None;
let mut current_branch: Option<String> = None;
let mut current_tag: Option<String> = None;
let mut current_spec: Option<GemSpec> = None;
let lines: Vec<&str> = input.lines().collect();
for (line_idx, &line) in lines.iter().enumerate() {
let _line_number = line_idx + 1;
if line.trim().is_empty() {
if let Some(spec) = current_spec.take() {
specs.push(spec);
}
continue;
}
let indent = count_indent(line);
let trimmed = line.trim();
if indent == 0 {
if let Some(spec) = current_spec.take() {
specs.push(spec);
}
finalize_source(
§ion,
&mut sources,
&mut current_remote,
&mut current_revision,
&mut current_branch,
&mut current_tag,
);
in_specs = false;
section = match trimmed {
"GIT" => Section::Git,
"GEM" => Section::Gem,
"PATH" => Section::Path,
"PLATFORMS" => Section::Platforms,
"DEPENDENCIES" => Section::Dependencies,
"RUBY VERSION" => Section::RubyVersion,
"BUNDLED WITH" => Section::BundledWith,
_ => Section::None,
};
continue;
}
match section {
Section::Git | Section::Gem | Section::Path => {
parse_source_line(
trimmed,
indent,
§ion,
&mut in_specs,
&mut current_remote,
&mut current_revision,
&mut current_branch,
&mut current_tag,
&mut current_spec,
&mut specs,
sources.len(),
);
}
Section::Platforms => {
if indent >= 2 {
platforms.push(trimmed.to_string());
}
}
Section::Dependencies => {
if indent >= 2 {
dependencies.push(parse_dependency_line(trimmed));
}
}
Section::RubyVersion => {
if indent >= 2 {
ruby_version = Some(trimmed.to_string());
}
}
Section::BundledWith => {
if indent >= 2 {
bundled_with = Some(trimmed.to_string());
}
}
Section::None => {}
}
}
if let Some(spec) = current_spec.take() {
specs.push(spec);
}
finalize_source(
§ion,
&mut sources,
&mut current_remote,
&mut current_revision,
&mut current_branch,
&mut current_tag,
);
if sources.is_empty() && specs.is_empty() {
return Err(ParseError::Empty);
}
Ok(Lockfile {
sources,
specs,
platforms,
dependencies,
ruby_version,
bundled_with,
})
}
fn count_indent(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn finalize_source(
section: &Section,
sources: &mut Vec<Source>,
current_remote: &mut Option<String>,
current_revision: &mut Option<String>,
current_branch: &mut Option<String>,
current_tag: &mut Option<String>,
) {
if let Some(remote) = current_remote.take() {
match section {
Section::Gem => {
sources.push(Source::Rubygems(RubygemsSource { remote }));
}
Section::Git => {
sources.push(Source::Git(GitSource {
remote,
revision: current_revision.take(),
branch: current_branch.take(),
tag: current_tag.take(),
}));
}
Section::Path => {
sources.push(Source::Path(PathSource { remote }));
}
_ => {}
}
}
*current_revision = None;
*current_branch = None;
*current_tag = None;
}
#[allow(clippy::too_many_arguments)]
fn parse_source_line(
trimmed: &str,
indent: usize,
_section: &Section,
in_specs: &mut bool,
current_remote: &mut Option<String>,
current_revision: &mut Option<String>,
current_branch: &mut Option<String>,
current_tag: &mut Option<String>,
current_spec: &mut Option<GemSpec>,
specs: &mut Vec<GemSpec>,
source_index: usize,
) {
if indent == 2 {
if let Some(value) = trimmed.strip_prefix("remote:") {
*current_remote = Some(value.trim().to_string());
*in_specs = false;
} else if let Some(value) = trimmed.strip_prefix("revision:") {
*current_revision = Some(value.trim().to_string());
} else if let Some(value) = trimmed.strip_prefix("branch:") {
*current_branch = Some(value.trim().to_string());
} else if let Some(value) = trimmed.strip_prefix("tag:") {
*current_tag = Some(value.trim().to_string());
} else if trimmed == "specs:" {
*in_specs = true;
}
return;
}
if !*in_specs {
return;
}
if indent == 4 {
if let Some(spec) = current_spec.take() {
specs.push(spec);
}
if let Some(spec) = parse_gem_spec_line(trimmed, source_index) {
*current_spec = Some(spec);
}
return;
}
if indent == 6
&& let Some(spec) = current_spec
{
spec.dependencies.push(parse_gem_dependency(trimmed));
}
}
fn parse_gem_spec_line(trimmed: &str, source_index: usize) -> Option<GemSpec> {
let (name, rest) = trimmed.split_once(' ')?;
let version_str = rest.strip_prefix('(')?.strip_suffix(')')?;
let (version, platform) = parse_version_platform(version_str);
Some(GemSpec {
name: name.to_string(),
version,
platform,
dependencies: Vec::new(),
source_index,
})
}
fn parse_version_platform(input: &str) -> (String, Option<String>) {
let platform_patterns = [
"x86_64-linux-gnu",
"x86_64-linux-musl",
"x86_64-linux",
"x86_64-darwin",
"x86-linux",
"x86-mingw32",
"x86-mswin32",
"x64-mingw32",
"x64-mingw-ucrt",
"arm64-darwin",
"aarch64-linux-gnu",
"aarch64-linux-musl",
"aarch64-linux",
"arm-linux-gnu",
"arm-linux-musl",
"arm-linux",
"java",
"jruby",
"mswin32",
"mingw32",
"universal-darwin",
];
for pattern in &platform_patterns {
if let Some(prefix) = input.strip_suffix(pattern)
&& let Some(version) = prefix.strip_suffix('-')
{
return (version.to_string(), Some(pattern.to_string()));
}
}
for (pos, _) in input.match_indices('-') {
let after = &input[pos + 1..];
if after.starts_with("x86")
|| after.starts_with("x64")
|| after.starts_with("arm")
|| after.starts_with("aarch")
|| after == "java"
|| after == "jruby"
|| after.starts_with("universal")
|| after.contains("mingw")
|| after.contains("mswin")
{
return (input[..pos].to_string(), Some(after.to_string()));
}
}
(input.to_string(), None)
}
fn parse_gem_dependency(trimmed: &str) -> GemDependency {
if let Some(paren_start) = trimmed.find('(') {
let name = trimmed[..paren_start].trim();
let constraint = trimmed[paren_start + 1..]
.strip_suffix(')')
.unwrap_or(&trimmed[paren_start + 1..])
.trim();
GemDependency {
name: name.to_string(),
requirement: if constraint.is_empty() {
None
} else {
Some(constraint.to_string())
},
}
} else {
GemDependency {
name: trimmed.to_string(),
requirement: None,
}
}
}
fn parse_dependency_line(trimmed: &str) -> Dependency {
let pinned = trimmed.ends_with('!');
let trimmed = if pinned {
trimmed.strip_suffix('!').unwrap().trim()
} else {
trimmed
};
if let Some(paren_start) = trimmed.find('(') {
let name = trimmed[..paren_start].trim();
let constraint = trimmed[paren_start + 1..]
.strip_suffix(')')
.unwrap_or(&trimmed[paren_start + 1..])
.trim();
Dependency {
name: name.to_string(),
requirement: if constraint.is_empty() {
None
} else {
Some(constraint.to_string())
},
pinned,
}
} else {
Dependency {
name: trimmed.to_string(),
requirement: None,
pinned,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_secure_lockfile() {
let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
let lockfile = parse(input).unwrap();
assert_eq!(lockfile.sources.len(), 1);
assert_eq!(
lockfile.sources[0],
Source::Rubygems(RubygemsSource {
remote: "https://rubygems.org/".to_string(),
})
);
assert_eq!(lockfile.platforms, vec!["ruby", "x86_64-linux"]);
assert_eq!(lockfile.bundled_with, Some("2.3.6".to_string()));
assert_eq!(lockfile.dependencies.len(), 2);
assert_eq!(lockfile.dependencies[0].name, "rails");
assert_eq!(
lockfile.dependencies[0].requirement,
Some("~> 5.2".to_string())
);
assert!(!lockfile.dependencies[0].pinned);
}
#[test]
fn parse_secure_specs() {
let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
let lockfile = parse(input).unwrap();
let actioncable = lockfile.find_spec("actioncable").unwrap();
assert_eq!(actioncable.version, "5.2.8");
assert_eq!(actioncable.dependencies.len(), 3);
assert_eq!(actioncable.dependencies[0].name, "actionpack");
assert_eq!(
actioncable.dependencies[0].requirement,
Some("= 5.2.8".to_string())
);
let nokogiri_specs = lockfile.find_specs("nokogiri");
assert_eq!(nokogiri_specs.len(), 2);
let nokogiri_plain = nokogiri_specs
.iter()
.find(|s| s.platform.is_none())
.unwrap();
assert_eq!(nokogiri_plain.version, "1.13.10");
assert_eq!(nokogiri_plain.dependencies.len(), 2);
let nokogiri_linux = nokogiri_specs
.iter()
.find(|s| s.platform.as_deref() == Some("x86_64-linux"))
.unwrap();
assert_eq!(nokogiri_linux.version, "1.13.10");
assert_eq!(nokogiri_linux.dependencies.len(), 1); }
#[test]
fn parse_secure_gem_count() {
let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
let lockfile = parse(input).unwrap();
let unique_names: std::collections::HashSet<&str> =
lockfile.specs.iter().map(|s| s.name.as_str()).collect();
assert!(unique_names.len() >= 30);
}
#[test]
fn parse_insecure_sources_lockfile() {
let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
let lockfile = parse(input).unwrap();
assert_eq!(lockfile.sources.len(), 2);
match &lockfile.sources[0] {
Source::Git(git) => {
assert_eq!(git.remote, "git://github.com/rails/jquery-rails.git");
assert_eq!(
git.revision,
Some("a8b003d726522cf663611c114d8f0e79abf8d200".to_string())
);
}
other => panic!("expected Git source, got {:?}", other),
}
match &lockfile.sources[1] {
Source::Rubygems(gem) => {
assert_eq!(gem.remote, "http://rubygems.org/");
}
other => panic!("expected Rubygems source, got {:?}", other),
}
}
#[test]
fn parse_insecure_git_source_specs() {
let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
let lockfile = parse(input).unwrap();
let jquery = lockfile.find_spec("jquery-rails").unwrap();
assert_eq!(jquery.version, "4.4.0");
assert_eq!(jquery.source_index, 0);
assert_eq!(jquery.dependencies.len(), 3);
}
#[test]
fn parse_insecure_pinned_dependency() {
let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
let lockfile = parse(input).unwrap();
let jquery_dep = lockfile
.dependencies
.iter()
.find(|d| d.name == "jquery-rails")
.unwrap();
assert!(jquery_dep.pinned);
assert!(jquery_dep.requirement.is_none());
let rails_dep = lockfile
.dependencies
.iter()
.find(|d| d.name == "rails")
.unwrap();
assert!(!rails_dep.pinned);
assert!(rails_dep.requirement.is_none());
}
#[test]
fn parse_unpatched_gems_lockfile() {
let input = include_str!("../../tests/fixtures/unpatched_gems/Gemfile.lock");
let lockfile = parse(input).unwrap();
assert_eq!(lockfile.sources.len(), 1);
assert_eq!(lockfile.bundled_with, Some("2.2.0".to_string()));
let activerecord = lockfile.find_spec("activerecord").unwrap();
assert_eq!(activerecord.version, "3.2.10");
assert_eq!(lockfile.dependencies.len(), 1);
assert_eq!(lockfile.dependencies[0].name, "activerecord");
assert_eq!(
lockfile.dependencies[0].requirement,
Some("= 3.2.10".to_string())
);
}
#[test]
fn parse_version_platform_plain() {
let (v, p) = parse_version_platform("1.13.10");
assert_eq!(v, "1.13.10");
assert_eq!(p, None);
}
#[test]
fn parse_version_platform_with_linux() {
let (v, p) = parse_version_platform("1.13.10-x86_64-linux");
assert_eq!(v, "1.13.10");
assert_eq!(p, Some("x86_64-linux".to_string()));
}
#[test]
fn parse_version_platform_java() {
let (v, p) = parse_version_platform("9.2.14.0-java");
assert_eq!(v, "9.2.14.0");
assert_eq!(p, Some("java".to_string()));
}
#[test]
fn parse_version_platform_darwin() {
let (v, p) = parse_version_platform("1.13.10-arm64-darwin");
assert_eq!(v, "1.13.10");
assert_eq!(p, Some("arm64-darwin".to_string()));
}
#[test]
fn parse_version_platform_musl() {
let (v, p) = parse_version_platform("1.19.1-aarch64-linux-musl");
assert_eq!(v, "1.19.1");
assert_eq!(p, Some("aarch64-linux-musl".to_string()));
}
#[test]
fn parse_version_platform_x86_musl() {
let (v, p) = parse_version_platform("1.19.1-x86_64-linux-musl");
assert_eq!(v, "1.19.1");
assert_eq!(p, Some("x86_64-linux-musl".to_string()));
}
#[test]
fn parse_version_platform_gnu() {
let (v, p) = parse_version_platform("1.19.1-x86_64-linux-gnu");
assert_eq!(v, "1.19.1");
assert_eq!(p, Some("x86_64-linux-gnu".to_string()));
}
#[test]
fn parse_dependency_with_constraint() {
let dep = parse_dependency_line("rails (~> 5.2)");
assert_eq!(dep.name, "rails");
assert_eq!(dep.requirement, Some("~> 5.2".to_string()));
assert!(!dep.pinned);
}
#[test]
fn parse_dependency_pinned() {
let dep = parse_dependency_line("jquery-rails!");
assert_eq!(dep.name, "jquery-rails");
assert!(dep.requirement.is_none());
assert!(dep.pinned);
}
#[test]
fn parse_dependency_plain() {
let dep = parse_dependency_line("rails");
assert_eq!(dep.name, "rails");
assert!(dep.requirement.is_none());
assert!(!dep.pinned);
}
#[test]
fn parse_gem_dep_with_constraint() {
let dep = parse_gem_dependency("actionpack (= 5.2.8)");
assert_eq!(dep.name, "actionpack");
assert_eq!(dep.requirement, Some("= 5.2.8".to_string()));
}
#[test]
fn parse_gem_dep_compound_constraint() {
let dep = parse_gem_dependency("rack (~> 2.0, >= 2.0.8)");
assert_eq!(dep.name, "rack");
assert_eq!(dep.requirement, Some("~> 2.0, >= 2.0.8".to_string()));
}
#[test]
fn parse_gem_dep_no_constraint() {
let dep = parse_gem_dependency("method_source");
assert_eq!(dep.name, "method_source");
assert!(dep.requirement.is_none());
}
#[test]
fn parse_empty_input() {
let result = parse("");
assert!(result.is_err());
}
#[test]
fn parse_minimal_lockfile() {
let input = "\
GEM
remote: https://rubygems.org/
specs:
rack (2.2.0)
PLATFORMS
ruby
DEPENDENCIES
rack
";
let lockfile = parse(input).unwrap();
assert_eq!(lockfile.specs.len(), 1);
assert_eq!(lockfile.specs[0].name, "rack");
assert_eq!(lockfile.specs[0].version, "2.2.0");
assert_eq!(lockfile.platforms, vec!["ruby"]);
assert_eq!(lockfile.dependencies.len(), 1);
}
#[test]
fn parse_path_source() {
let input = "\
PATH
remote: .
specs:
my_gem (0.1.0)
GEM
remote: https://rubygems.org/
specs:
rack (2.0.0)
PLATFORMS
ruby
DEPENDENCIES
my_gem!
rack
";
let lockfile = parse(input).unwrap();
assert_eq!(lockfile.sources.len(), 2);
match &lockfile.sources[0] {
Source::Path(p) => assert_eq!(p.remote, "."),
other => panic!("expected Path source, got {:?}", other),
}
let my_gem = lockfile.find_spec("my_gem").unwrap();
assert_eq!(my_gem.version, "0.1.0");
assert_eq!(my_gem.source_index, 0);
}
#[test]
fn parse_git_source_with_tag() {
let input = "\
GIT
remote: https://github.com/foo/bar.git
revision: abc123
tag: v1.0.0
specs:
bar (1.0.0)
GEM
remote: https://rubygems.org/
specs:
rack (2.0.0)
PLATFORMS
ruby
DEPENDENCIES
bar!
rack
";
let lockfile = parse(input).unwrap();
match &lockfile.sources[0] {
Source::Git(git) => {
assert_eq!(git.tag, Some("v1.0.0".to_string()));
assert_eq!(git.revision, Some("abc123".to_string()));
}
other => panic!("expected Git source, got {:?}", other),
}
}
#[test]
fn parse_ruby_version_section() {
let input = "\
GEM
remote: https://rubygems.org/
specs:
rack (2.0.0)
PLATFORMS
ruby
DEPENDENCIES
rack
RUBY VERSION
ruby 3.0.0p0
BUNDLED WITH
2.3.6
";
let lockfile = parse(input).unwrap();
assert_eq!(lockfile.ruby_version, Some("ruby 3.0.0p0".to_string()));
}
#[test]
fn all_specs_have_valid_source_index() {
let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
let lockfile = parse(input).unwrap();
for spec in &lockfile.specs {
assert!(
spec.source_index < lockfile.sources.len(),
"spec {} has source_index {} but only {} sources",
spec.name,
spec.source_index,
lockfile.sources.len()
);
}
}
}