class Dependabot::Cargo::UpdateChecker::VersionResolver

Constants

BRANCH_NOT_FOUND_REGEX
OBJECT_PATTERN
REF_NOT_FOUND_REGEX
REVSPEC_PATTERN
UNABLE_TO_UPDATE

Attributes

credentials[R]
dependency[R]
original_dependency_files[R]
prepared_dependency_files[R]

Public Class Methods

new(dependency:, credentials:, original_dependency_files:, prepared_dependency_files:) click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 23
def initialize(dependency:, credentials:,
               original_dependency_files:, prepared_dependency_files:)
  @dependency = dependency
  @prepared_dependency_files = prepared_dependency_files
  @original_dependency_files = original_dependency_files
  @credentials = credentials
end

Public Instance Methods

latest_resolvable_version() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 31
def latest_resolvable_version
  @latest_resolvable_version ||= fetch_latest_resolvable_version
end

Private Instance Methods

better_specification_needed?(error) click to toggle source

rubocop:disable Metrics/PerceivedComplexity rubocop:disable Metrics/CyclomaticComplexity rubocop:disable Metrics/AbcSize

# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 86
def better_specification_needed?(error)
  return false if @custom_specification
  return false unless error.message.match?(/specification .* is ambigu/)

  spec_options = error.message.gsub(/.*following:\n/m, "").
                 lines.map(&:strip)

  ver = if git_dependency? && git_dependency_version
          git_dependency_version
        else
          dependency.version
        end

  if spec_options.count { |s| s.end_with?(ver) } == 1
    @custom_specification = spec_options.find { |s| s.end_with?(ver) }
    return true
  elsif spec_options.count { |s| s.end_with?(ver) } > 1
    spec_options.select! { |s| s.end_with?(ver) }
  end

  if git_dependency? && git_source_url &&
     spec_options.count { |s| s.include?(git_source_url) } >= 1
    spec_options.select! { |s| s.include?(git_source_url) }
  end

  @custom_specification = spec_options.first
  true
end
check_rust_workspace_root() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 160
def check_rust_workspace_root
  cargo_toml = original_dependency_files.
               select { |f| f.name.end_with?("../Cargo.toml") }.
               max_by { |f| f.name.length }
  return unless TomlRB.parse(cargo_toml.content)["workspace"]

  msg = "This project is part of a Rust workspace but is not the "\
        "workspace root."\

  if cargo_toml.directory != "/"
    msg += "Please update your settings so Dependabot points at the "\
           "workspace root instead of #{cargo_toml.directory}."
  end
  raise Dependabot::DependencyFileNotResolvable, msg
end
dependency_spec() click to toggle source

rubocop:enable Metrics/AbcSize rubocop:enable Metrics/CyclomaticComplexity rubocop:enable Metrics/PerceivedComplexity

# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 118
def dependency_spec
  return @custom_specification if @custom_specification

  spec = dependency.name

  if git_dependency?
    spec += ":#{git_dependency_version}" if git_dependency_version
  elsif dependency.version
    spec += ":#{dependency.version}"
    spec = "https://github.com/rust-lang/crates.io-index#" + spec
  end

  spec
end
dummy_app_content() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 365
def dummy_app_content
  %{fn main() {\nprintln!("Hello, world!");\n}}
end
fetch_latest_resolvable_version() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 40
def fetch_latest_resolvable_version
  base_directory = prepared_dependency_files.first.directory
  SharedHelpers.in_a_temporary_directory(base_directory) do
    write_temporary_dependency_files

    SharedHelpers.with_git_configured(credentials: credentials) do
      # Shell out to Cargo, which handles everything for us, and does
      # so without doing an install (so it's fast).
      run_cargo_command("cargo update -p #{dependency_spec} --verbose")
    end

    updated_version = fetch_version_from_new_lockfile

    return if updated_version.nil?
    return updated_version if git_dependency?

    version_class.new(updated_version)
  end
rescue SharedHelpers::HelperSubprocessFailed => e
  retry if better_specification_needed?(e)
  handle_cargo_errors(e)
end
fetch_version_from_new_lockfile() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 63
def fetch_version_from_new_lockfile
  check_rust_workspace_root unless File.exist?("Cargo.lock")
  lockfile_content = File.read("Cargo.lock")
  versions = TomlRB.parse(lockfile_content).fetch("package").
             select { |p| p["name"] == dependency.name }

  updated_version =
    if dependency.top_level?
      versions.max_by { |p| version_class.new(p.fetch("version")) }
    else
      versions.min_by { |p| version_class.new(p.fetch("version")) }
    end

  if git_dependency?
    updated_version.fetch("source").split("#").last
  else
    updated_version.fetch("version")
  end
end
git_dependency?() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 407
def git_dependency?
  GitCommitChecker.new(
    dependency: dependency,
    credentials: credentials
  ).git_dependency?
end
git_dependency_version() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 349
def git_dependency_version
  return unless lockfile

  TomlRB.parse(lockfile.content).
    fetch("package", []).
    select { |p| p["name"] == dependency.name }.
    find { |p| p["source"].end_with?(dependency.version) }.
    fetch("version")
end
git_source_url() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 359
def git_source_url
  dependency.requirements.
    find { |r| r.dig(:source, :type) == "git" }&.
    dig(:source, :url)
end
handle_cargo_errors(error) click to toggle source

rubocop:disable Metrics/AbcSize rubocop:disable Metrics/PerceivedComplexity rubocop:disable Metrics/MethodLength

# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 179
def handle_cargo_errors(error)
  if error.message.include?("does not have these features")
    # TODO: Ideally we should update the declaration not to ask
    # for the specified features
    return nil
  end

  if error.message.include?("authenticate when downloading repo") ||
     error.message.include?("HTTP 200 response: got 401")
    # Check all dependencies for reachability (so that we raise a
    # consistent error)
    urls = unreachable_git_urls

    if urls.none?
      url = error.message.match(UNABLE_TO_UPDATE).
            named_captures.fetch("url").split(/[#?]/).first
      raise if reachable_git_urls.include?(url)

      urls << url
    end

    raise Dependabot::GitDependenciesNotReachable, urls
  end

  if error.message.match?(BRANCH_NOT_FOUND_REGEX)
    dependency_url =
      error.message.match(BRANCH_NOT_FOUND_REGEX).
      named_captures.fetch("url").split(/[#?]/).first
    raise Dependabot::GitDependencyReferenceNotFound, dependency_url
  end

  if error.message.match?(REF_NOT_FOUND_REGEX)
    dependency_url =
      error.message.match(REF_NOT_FOUND_REGEX).
      named_captures.fetch("url").split(/[#?]/).first
    raise Dependabot::GitDependencyReferenceNotFound, dependency_url
  end

  if workspace_native_library_update_error?(error.message)
    # This happens when we're updating one part of a workspace which
    # triggers an update of a subdependency that uses a native library,
    # whilst leaving another part of the workspace using an older
    # version. Ideally we would prevent the subdependency update.
    return nil
  end

  if git_dependency? && error.message.include?("no matching package")
    # This happens when updating a git dependency whose version has
    # changed from a release to a pre-release version
    return nil
  end

  if error.message.include?("all possible versions conflict")
    # This happens when a top-level requirement locks us to an old
    # patch release of a dependency that is a sub-dep of what we're
    # updating. It's (probably) a Cargo bug.
    return nil
  end

  raise Dependabot::DependencyFileNotResolvable, error.message if resolvability_error?(error.message)

  raise error
end
lockfile() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 397
def lockfile
  @lockfile ||= prepared_dependency_files.
                find { |f| f.name == "Cargo.lock" }
end
original_manifest_files() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 391
def original_manifest_files
  @original_manifest_files ||=
    original_dependency_files.
    select { |f| f.name.end_with?("Cargo.toml") }
end
original_requirements_resolvable?() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 294
def original_requirements_resolvable?
  base_directory = original_dependency_files.first.directory
  SharedHelpers.in_a_temporary_directory(base_directory) do
    write_temporary_dependency_files(prepared: false)

    SharedHelpers.with_git_configured(credentials: credentials) do
      run_cargo_command("cargo update -p #{dependency_spec} --verbose")
    end
  end

  true
rescue SharedHelpers::HelperSubprocessFailed => e
  raise unless e.message.include?("no matching version") ||
               e.message.include?("failed to select a version") ||
               e.message.include?("no matching package named") ||
               e.message.include?("failed to parse manifest") ||
               e.message.include?("failed to update submodule")

  false
end
prepared_manifest_files() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 385
def prepared_manifest_files
  @prepared_manifest_files ||=
    prepared_dependency_files.
    select { |f| f.name.end_with?("Cargo.toml") }
end
reachable_git_urls() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 277
def reachable_git_urls
  return @reachable_git_urls if defined?(@reachable_git_urls)

  unreachable_git_urls
  @reachable_git_urls
end
resolvability_error?(message) click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 284
def resolvability_error?(message)
  return true if message.include?("failed to parse lock")
  return true if message.include?("believes it's in a workspace")
  return true if message.include?("wasn't a root")
  return true if message.include?("requires a nightly version")
  return true if message.match?(/feature `[^\`]+` is required/)

  !original_requirements_resolvable?
end
run_cargo_command(command) click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 133
def run_cargo_command(command)
  start = Time.now
  command = SharedHelpers.escape_command(command)
  stdout, process = Open3.capture2e(command)
  time_taken = Time.now - start

  # Raise an error with the output from the shell session if Cargo
  # returns a non-zero status
  return if process.success?

  raise SharedHelpers::HelperSubprocessFailed.new(
    message: stdout,
    error_context: {
      command: command,
      time_taken: time_taken,
      process_exit_value: process.to_s
    }
  )
end
sanitized_manifest_content(content) click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 369
def sanitized_manifest_content(content)
  object = TomlRB.parse(content)

  object.delete("bin")

  object["package"].delete("default-run") if object.dig("package", "default-run")

  package_name = object.dig("package", "name")
  return TomlRB.dump(object) unless package_name&.match?(/[\{\}]/)

  raise "Sanitizing name for pkg with lockfile. Investigate!" if lockfile

  object["package"]["name"] = "sanitized"
  TomlRB.dump(object)
end
toolchain() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 402
def toolchain
  @toolchain ||= prepared_dependency_files.
                 find { |f| f.name == "rust-toolchain" }
end
unreachable_git_urls() click to toggle source

rubocop:enable Metrics/AbcSize rubocop:enable Metrics/PerceivedComplexity rubocop:enable Metrics/MethodLength

# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 246
def unreachable_git_urls
  return @unreachable_git_urls if defined?(@unreachable_git_urls)

  @unreachable_git_urls = []
  @reachable_git_urls = []

  dependencies = FileParser.new(
    dependency_files: original_dependency_files,
    source: nil
  ).parse

  dependencies.each do |dep|
    checker = GitCommitChecker.new(
      dependency: dep,
      credentials: credentials
    )
    next unless checker.git_dependency?

    url = dep.requirements.find { |r| r.dig(:source, :type) == "git" }.
          fetch(:source).fetch(:url)

    if checker.git_repo_reachable?
      @reachable_git_urls << url
    else
      @unreachable_git_urls << url
    end
  end

  @unreachable_git_urls
end
version_class() click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 421
def version_class
  Cargo::Version
end
virtual_manifest?(file) click to toggle source

When the package table is not present in a workspace manifest, it is called a virtual manifest: doc.rust-lang.org/cargo/reference/ manifest.html#virtual-manifest

# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 417
def virtual_manifest?(file)
  !file.content.include?("[package]")
end
workspace_native_library_update_error?(message) click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 315
def workspace_native_library_update_error?(message)
  return unless message.include?("native library")

  library_count = prepared_manifest_files.count do |file|
    package_name = TomlRB.parse(file.content).dig("package", "name")
    next false unless package_name

    message.include?("depended on by `#{package_name} ")
  end

  library_count >= 2
end
write_manifest_files(prepared: true) click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 328
def write_manifest_files(prepared: true)
  manifest_files = if prepared then prepared_manifest_files
                   else original_manifest_files
                   end

  manifest_files.each do |file|
    path = file.name
    dir = Pathname.new(path).dirname
    FileUtils.mkdir_p(dir)
    File.write(file.name, sanitized_manifest_content(file.content))

    next if virtual_manifest?(file)

    File.write(File.join(dir, "build.rs"), dummy_app_content)

    FileUtils.mkdir_p(File.join(dir, "src"))
    File.write(File.join(dir, "src/lib.rs"), dummy_app_content)
    File.write(File.join(dir, "src/main.rs"), dummy_app_content)
  end
end
write_temporary_dependency_files(prepared: true) click to toggle source
# File lib/dependabot/cargo/update_checker/version_resolver.rb, line 153
def write_temporary_dependency_files(prepared: true)
  write_manifest_files(prepared: prepared)

  File.write(lockfile.name, lockfile.content) if lockfile
  File.write(toolchain.name, toolchain.content) if toolchain
end