class Dependabot::Composer::UpdateChecker::VersionResolver

Constants

FAILED_GIT_CLONE
FAILED_GIT_CLONE_WITH_MIRROR
MISSING_EXPLICIT_PLATFORM_REQ_REGEX
MISSING_IMPLICIT_PLATFORM_REQ_REGEX
SOURCE_TIMED_OUT_REGEX
VERSION_REGEX

Attributes

composer_platform_extensions[R]
credentials[R]
dependency[R]
dependency_files[R]
latest_allowable_version[R]
requirements_to_unlock[R]

Public Class Methods

new(credentials:, dependency:, dependency_files:, requirements_to_unlock:, latest_allowable_version:) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 43
def initialize(credentials:, dependency:, dependency_files:,
               requirements_to_unlock:, latest_allowable_version:)
  @credentials                  = credentials
  @dependency                   = dependency
  @dependency_files             = dependency_files
  @requirements_to_unlock       = requirements_to_unlock
  @latest_allowable_version     = latest_allowable_version
  @composer_platform_extensions = initial_platform
end

Public Instance Methods

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

Private Instance Methods

add_temporary_platform_extensions(content) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 160
def add_temporary_platform_extensions(content)
  json = JSON.parse(content)

  composer_platform_extensions.each do |extension, requirements|
    next unless version_for_reqs(requirements)

    json["config"] ||= {}
    json["config"]["platform"] ||= {}
    json["config"]["platform"][extension] =
      version_for_reqs(requirements)
  end

  JSON.dump(json)
end
auth_json() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 489
def auth_json
  @auth_json ||= dependency_files.find { |f| f.name == "auth.json" }
end
check_original_requirements_resolvable() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 373
def check_original_requirements_resolvable
  base_directory = dependency_files.first.directory
  SharedHelpers.in_a_temporary_directory(base_directory) do
    write_temporary_dependency_files(unlock_requirement: false)

    run_update_checker
  end

  true
rescue SharedHelpers::HelperSubprocessFailed => e
  if e.message.match?(MISSING_EXPLICIT_PLATFORM_REQ_REGEX)
    missing_extensions =
      e.message.scan(MISSING_EXPLICIT_PLATFORM_REQ_REGEX).
      map do |extension_string|
        name, requirement = extension_string.strip.split(" ", 2)
        { name: name, requirement: requirement }
      end
    raise MissingExtensions, missing_extensions
  elsif e.message.match?(MISSING_IMPLICIT_PLATFORM_REQ_REGEX) &&
        implicit_platform_reqs_satisfiable?(e.message)
    missing_extensions =
      e.message.scan(MISSING_IMPLICIT_PLATFORM_REQ_REGEX).
      map do |extension_string|
        name, requirement = extension_string.strip.split(" ", 2)
        { name: name, requirement: requirement }
      end
    raise MissingExtensions, missing_extensions
  end

  raise Dependabot::DependencyFileNotResolvable, e.message
end
composer_file() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 474
def composer_file
  @composer_file ||=
    dependency_files.find { |f| f.name == "composer.json" }
end
composer_version() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 443
def composer_version
  parsed_lockfile_or_nil = lockfile ? parsed_lockfile : nil
  @composer_version ||= Helpers.composer_version(parsed_composer_file, parsed_lockfile_or_nil)
end
fetch_latest_resolvable_version() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 63
def fetch_latest_resolvable_version
  version = fetch_latest_resolvable_version_string
  return if version.nil?
  return unless Composer::Version.correct?(version)

  Composer::Version.new(version)
rescue MissingExtensions => e
  previous_extensions = composer_platform_extensions.dup
  update_required_extensions(e.extensions)
  raise if previous_extensions == composer_platform_extensions

  retry
end
fetch_latest_resolvable_version_string() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 77
def fetch_latest_resolvable_version_string
  base_directory = dependency_files.first.directory
  SharedHelpers.in_a_temporary_directory(base_directory) do
    write_temporary_dependency_files
    run_update_checker
  end
rescue SharedHelpers::HelperSubprocessFailed => e
  retry_count ||= 0
  retry_count += 1
  retry if transitory_failure?(e) && retry_count < 2
  handle_composer_errors(e)
end
git_credentials() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 500
def git_credentials
  credentials.
    select { |cred| cred["type"] == "git_source" }.
    select { |cred| cred["password"] }
end
handle_composer_errors(error) click to toggle source

TODO: Extract error handling and share between the lockfile updater

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

# File lib/dependabot/composer/update_checker/version_resolver.rb, line 242
def handle_composer_errors(error)
  sanitized_message = remove_url_credentials(error.message)

  # Special case for Laravel Nova, which will fall back to attempting
  # to close a private repo if given invalid (or no) credentials
  if error.message.include?("github.com/laravel/nova.git")
    raise PrivateSourceAuthenticationFailure, "nova.laravel.com"
  end

  if error.message.match?(FAILED_GIT_CLONE_WITH_MIRROR)
    dependency_url = error.message.match(FAILED_GIT_CLONE_WITH_MIRROR).named_captures.fetch("url")
    raise Dependabot::GitDependenciesNotReachable, dependency_url
  elsif error.message.match?(FAILED_GIT_CLONE)
    dependency_url = error.message.match(FAILED_GIT_CLONE).named_captures.fetch("url")
    raise Dependabot::GitDependenciesNotReachable, dependency_url
  elsif unresolvable_error?(error)
    raise Dependabot::DependencyFileNotResolvable, sanitized_message
  elsif error.message.match?(MISSING_EXPLICIT_PLATFORM_REQ_REGEX)
    # These errors occur when platform requirements declared explicitly
    # in the composer.json aren't met.
    missing_extensions =
      error.message.scan(MISSING_EXPLICIT_PLATFORM_REQ_REGEX).
      map do |extension_string|
        name, requirement = extension_string.strip.split(" ", 2)
        { name: name, requirement: requirement }
      end
    raise MissingExtensions, missing_extensions
  elsif error.message.match?(MISSING_IMPLICIT_PLATFORM_REQ_REGEX) &&
        !library? &&
        !initial_platform.empty? &&
        implicit_platform_reqs_satisfiable?(error.message)
    missing_extensions =
      error.message.scan(MISSING_IMPLICIT_PLATFORM_REQ_REGEX).
      map do |extension_string|
        name, requirement = extension_string.strip.split(" ", 2)
        { name: name, requirement: requirement }
      end

    missing_extension = missing_extensions.find do |hash|
      existing_reqs = composer_platform_extensions[hash[:name]] || []
      version_for_reqs(existing_reqs + [hash[:requirement]])
    end

    raise MissingExtensions, [missing_extension]
  elsif error.message.include?("cannot require itself") ||
        error.message.include?('packages.json" file could not be down')
    raise Dependabot::DependencyFileNotResolvable, error.message
  elsif error.message.include?("No driver found to handle VCS") &&
        !error.message.include?("@") && !error.message.include?("://")
    msg = "Dependabot detected a VCS requirement with a local path, "\
          "rather than a URL. Dependabot does not support this "\
          "setup.\n\nThe underlying error was:\n\n#{error.message}"
    raise Dependabot::DependencyFileNotResolvable, msg
  elsif error.message.include?("requirements could not be resolved")
    # If there's no lockfile, there's no difference between running
    # `composer install` and `composer update`, so we can easily check
    # whether the existing requirements are resolvable for an install
    check_original_requirements_resolvable unless lockfile

    # If there *is* a lockfile we can't confidently distinguish between
    # cases where we can't install and cases where we can't update. For
    # now, we therefore just ignore the dependency.
    nil
  elsif error.message.include?("URL required authentication") ||
        error.message.include?("403 Forbidden")
    source = error.message.match(%r{https?://(?<source>[^/]+)/}).named_captures.fetch("source")
    raise Dependabot::PrivateSourceAuthenticationFailure, source
  elsif error.message.match?(SOURCE_TIMED_OUT_REGEX)
    url = error.message.match(SOURCE_TIMED_OUT_REGEX).named_captures.fetch("url")
    raise if url.include?("packagist.org")

    source = url.gsub(%r{/packages.json$}, "")
    raise Dependabot::PrivateSourceTimedOut, source
  elsif error.message.start_with?("Allowed memory size") || error.message.start_with?("Out of memory")
    raise Dependabot::OutOfMemory
  elsif error.error_context[:process_termsig] == Dependabot::SharedHelpers::SIGKILL
    # If the helper was SIGKILL-ed, assume the OOMKiller did it
    raise Dependabot::OutOfMemory
  elsif error.message.start_with?("Package not found in updated") &&
        !dependency.top_level?
    # If we can't find the dependency in the composer.lock after an
    # update, but it was originally a sub-dependency, it's because the
    # dependency is no longer required and is just cruft in the
    # composer.json. In this case we just ignore the dependency.
    nil
  elsif error.message.include?("stefandoorn/sitemap-plugin-1.0.0.0") ||
        error.message.include?("simplethings/entity-audit-bundle-1.0.0")
    # We get a recurring error when attempting to update these repos
    # which doesn't recur locally and we can't figure out how to fix!
    #
    # Package is not installed: stefandoorn/sitemap-plugin-1.0.0.0
    nil
  elsif error.message.include?("does not match the expected JSON schema")
    msg = "Composer failed to parse your composer.json as it does not match the expected JSON schema.\n"\
          "Run `composer validate` to check your composer.json and composer.lock files.\n\n"\
          "See https://getcomposer.org/doc/04-schema.md for details on the schema."
    raise Dependabot::DependencyFileNotParseable, msg
  else
    raise error
  end
end
implicit_platform_reqs_satisfiable?(message) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 359
def implicit_platform_reqs_satisfiable?(message)
  missing_extensions =
    message.scan(MISSING_IMPLICIT_PLATFORM_REQ_REGEX).
    map do |extension_string|
      name, requirement = extension_string.strip.split(" ", 2)
      { name: name, requirement: requirement }
    end

  missing_extensions.any? do |hash|
    existing_reqs = composer_platform_extensions[hash[:name]] || []
    version_for_reqs(existing_reqs + [hash[:requirement]])
  end
end
initial_platform() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 448
def initial_platform
  platform_php = parsed_composer_file.dig("config", "platform", "php")

  platform = {}
  platform["php"] = [platform_php] if platform_php.is_a?(String) && requirement_valid?(platform_php)

  # NOTE: We *don't* include the require-dev PHP version in our initial
  # platform. If we fail to resolve with the PHP version specified in
  # `require` then it will be picked up in a subsequent iteration.
  requirement_php = parsed_composer_file.dig("require", "php")
  return platform unless requirement_php.is_a?(String)
  return platform unless requirement_valid?(requirement_php)

  platform["php"] ||= []
  platform["php"] << requirement_php
  platform
end
library?() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 355
def library?
  parsed_composer_file["type"] == "library"
end
lock_git_dependencies(content) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 175
def lock_git_dependencies(content)
  json = JSON.parse(content)

  FileParser::DEPENDENCY_GROUP_KEYS.each do |keys|
    next unless json[keys[:manifest]]

    json[keys[:manifest]].each do |name, req|
      next unless req.start_with?("dev-")
      next if req.include?("#")

      commit_sha = parsed_lockfile.
                   fetch(keys[:lockfile], []).
                   find { |d| d["name"] == name }&.
                   dig("source", "reference")
      updated_req_parts = req.split
      updated_req_parts[0] = updated_req_parts[0] + "##{commit_sha}"
      json[keys[:manifest]][name] = updated_req_parts.join(" ")
    end
  end

  JSON.dump(json)
end
lockfile() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 484
def lockfile
  @lockfile ||=
    dependency_files.find { |f| f.name == "composer.lock" }
end
parsed_composer_file() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 466
def parsed_composer_file
  @parsed_composer_file ||= JSON.parse(composer_file.content)
end
parsed_lockfile() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 470
def parsed_lockfile
  @parsed_lockfile ||= JSON.parse(lockfile.content)
end
path_dependency_files() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 479
def path_dependency_files
  @path_dependency_files ||=
    dependency_files.select { |f| f.name.end_with?("/composer.json") }
end
php_helper_path() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 439
def php_helper_path
  NativeHelpers.composer_helper_path(composer_version: composer_version)
end
prepared_composer_json_content(unlock_requirement: true) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 145
def prepared_composer_json_content(unlock_requirement: true)
  content = composer_file.content
  content = unlock_dep_being_updated(content) if unlock_requirement
  content = lock_git_dependencies(content) if lockfile
  content = add_temporary_platform_extensions(content)
  content
end
registry_credentials() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 506
def registry_credentials
  credentials.
    select { |cred| cred["type"] == "composer_repository" }.
    select { |cred| cred["password"] }
end
remove_url_credentials(message) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 512
def remove_url_credentials(message)
  message.gsub(%r{(?<=://)[^\s]*:[^\s]*(?=@)}, "****")
end
requirement_valid?(req_string) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 493
def requirement_valid?(req_string)
  Composer::Requirement.requirements_array(req_string)
  true
rescue Gem::Requirement::BadRequirementError
  false
end
run_update_checker() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 129
def run_update_checker
  SharedHelpers.with_git_configured(credentials: credentials) do
    SharedHelpers.run_helper_subprocess(
      command: "php -d memory_limit=-1 #{php_helper_path}",
      allow_unsafe_shell_command: true,
      function: "get_latest_resolvable_version",
      args: [
        Dir.pwd,
        dependency.name.downcase,
        git_credentials,
        registry_credentials
      ]
    )
  end
end
transitory_failure?(error) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 121
def transitory_failure?(error)
  return true if error.message.include?("404 Not Found")
  return true if error.message.include?("timed out")
  return true if error.message.include?("Temporary failure")

  error.message.include?("Content-Length mismatch")
end
unlock_dep_being_updated(content) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 153
def unlock_dep_being_updated(content)
  content.gsub(
    /"#{Regexp.escape(dependency.name)}"\s*:\s*".*"/,
    %("#{dependency.name}": "#{updated_version_requirement_string}")
  )
end
unresolvable_error?(error) click to toggle source

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

# File lib/dependabot/composer/update_checker/version_resolver.rb, line 348
def unresolvable_error?(error)
  error.message.start_with?("Could not parse version") ||
    error.message.include?("does not allow connections to http://") ||
    error.message.match?(/The `url` supplied for the path .* does not exist/) ||
    error.message.start_with?("Invalid version string")
end
update_required_extensions(additional_extensions) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 429
def update_required_extensions(additional_extensions)
  additional_extensions.each do |ext|
    composer_platform_extensions[ext.fetch(:name)] ||= []
    composer_platform_extensions[ext.fetch(:name)] +=
      [ext.fetch(:requirement)]
    composer_platform_extensions[ext.fetch(:name)] =
      composer_platform_extensions[ext.fetch(:name)].uniq
  end
end
updated_version_requirement_string() click to toggle source

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

# File lib/dependabot/composer/update_checker/version_resolver.rb, line 200
def updated_version_requirement_string
  lower_bound =
    if requirements_to_unlock == :none
      dependency.requirements.first&.fetch(:requirement) || ">= 0"
    elsif dependency.version
      ">= #{dependency.version}"
    else
      version_for_requirement =
        dependency.requirements.map { |r| r[:requirement] }.compact.
        reject { |req_string| req_string.start_with?("<") }.
        select { |req_string| req_string.match?(VERSION_REGEX) }.
        map { |req_string| req_string.match(VERSION_REGEX) }.
        select { |version| requirement_valid?(">= #{version}") }.
        max_by { |version| Composer::Version.new(version) }

      ">= #{version_for_requirement || 0}"
    end

  # Add the latest_allowable_version as an upper bound. This means
  # ignore conditions are considered when checking for the latest
  # resolvable version.
  #
  # NOTE: This isn't perfect. If v2.x is ignored and v3 is out but
  # unresolvable then the `latest_allowable_version` will be v3, and
  # we won't be ignoring v2.x releases like we should be.
  return lower_bound unless latest_allowable_version

  # If the original requirement is just a stability flag we append that
  # flag to the requirement
  return "<=#{latest_allowable_version}#{lower_bound.strip}" if lower_bound.strip.start_with?("@")

  lower_bound + ", <= #{latest_allowable_version}"
end
version_for_reqs(requirements) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 405
def version_for_reqs(requirements)
  req_arrays =
    requirements.
    map { |str| Composer::Requirement.requirements_array(str) }
  potential_versions =
    req_arrays.flatten.map do |req|
      op, version = req.requirements.first
      case op
      when ">" then version.bump
      when "<" then Composer::Version.new("0.0.1")
      else version
      end
    end

  version =
    potential_versions.
    find do |v|
      req_arrays.all? { |reqs| reqs.any? { |r| r.satisfied_by?(v) } }
    end
  return unless version

  version.to_s
end
write_auth_file() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 117
def write_auth_file
  File.write("auth.json", auth_json.content) if auth_json
end
write_dependency_file(unlock_requirement:) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 97
def write_dependency_file(unlock_requirement:)
  File.write(
    "composer.json",
    prepared_composer_json_content(
      unlock_requirement: unlock_requirement
    )
  )
end
write_lockfile() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 113
def write_lockfile
  File.write("composer.lock", lockfile.content) if lockfile
end
write_path_dependency_files() click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 106
def write_path_dependency_files
  path_dependency_files.each do |file|
    FileUtils.mkdir_p(Pathname.new(file.name).dirname)
    File.write(file.name, file.content)
  end
end
write_temporary_dependency_files(unlock_requirement: true) click to toggle source
# File lib/dependabot/composer/update_checker/version_resolver.rb, line 90
def write_temporary_dependency_files(unlock_requirement: true)
  write_dependency_file(unlock_requirement: unlock_requirement)
  write_path_dependency_files
  write_lockfile
  write_auth_file
end