class Dependabot::Composer::FileUpdater::LockfileUpdater

Constants

MISSING_ENV_VAR_REGEX
MISSING_EXPLICIT_PLATFORM_REQ_REGEX
MISSING_IMPLICIT_PLATFORM_REQ_REGEX

Attributes

composer_platform_extensions[R]
credentials[R]
dependencies[R]
dependency_files[R]

Public Class Methods

new(dependencies:, dependency_files:, credentials:) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 41
def initialize(dependencies:, dependency_files:, credentials:)
  @dependencies = dependencies
  @dependency_files = dependency_files
  @credentials = credentials
  @composer_platform_extensions = initial_platform
end

Public Instance Methods

updated_lockfile_content() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 48
def updated_lockfile_content
  @updated_lockfile_content ||= generate_updated_lockfile_content
rescue MissingExtensions => e
  previous_extensions = composer_platform_extensions.dup
  update_required_extensions(e.extensions)
  raise if previous_extensions == composer_platform_extensions

  retry
end

Private Instance Methods

add_temporary_platform_extensions(content) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 273
def add_temporary_platform_extensions(content)
  json = JSON.parse(content)

  composer_platform_extensions.each do |extension, 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/file_updater/lockfile_updater.rb, line 518
def auth_json
  @auth_json ||= dependency_files.find { |f| f.name == "auth.json" }
end
composer_json() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 508
def composer_json
  @composer_json ||=
    dependency_files.find { |f| f.name == "composer.json" }
end
composer_version() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 452
def composer_version
  @composer_version ||= Helpers.composer_version(parsed_composer_json, parsed_lockfile)
end
credentials_env() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 456
def credentials_env
  credentials.
    select { |c| c.fetch("type") == "php_environment_variable" }.
    map { |cred| [cred["env-key"], cred.fetch("env-value", "-")] }.
    to_h
end
dependency() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 87
def dependency
  # For now, we'll only ever be updating a single dependency for PHP
  dependencies.first
end
generate_updated_lockfile_content() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 63
def generate_updated_lockfile_content
  base_directory = dependency_files.first.directory
  SharedHelpers.in_a_temporary_directory(base_directory) do
    write_temporary_dependency_files

    updated_content = run_update_helper.fetch("composer.lock")

    updated_content = post_process_lockfile(updated_content)
    raise "Expected content to change!" if lockfile.content == updated_content

    updated_content
  end
rescue SharedHelpers::HelperSubprocessFailed => e
  retry_count ||= 0
  retry_count += 1
  retry if transitory_failure?(e) && retry_count <= 1
  if locked_git_dep_error?(e) && retry_count <= 1
    @lock_git_deps = false
    retry
  end

  handle_composer_errors(e)
end
git_credentials() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 463
def git_credentials
  credentials.
    select { |cred| cred.fetch("type") == "git_source" }.
    select { |cred| cred["password"] }
end
git_dependency_reference_error(error) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 333
def git_dependency_reference_error(error)
  ref = error.message.match(/checkout '(?<ref>.*?)'/).
        named_captures.fetch("ref")
  dependency_name =
    JSON.parse(lockfile.content).
    values_at("packages", "packages-dev").flatten(1).
    find { |dep| dep.dig("source", "reference") == ref }&.
    fetch("name")

  raise unless dependency_name

  raise GitDependencyReferenceNotFound, dependency_name
end
handle_composer_errors(error) click to toggle source

TODO: Extract error handling and share between the version resolver

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

# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 135
def handle_composer_errors(error)
  if 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]
  end

  raise git_dependency_reference_error(error) if error.message.start_with?("Failed to execute git checkout")

  # 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?(UpdateChecker::VersionResolver::FAILED_GIT_CLONE_WITH_MIRROR)
    dependency_url = error.message.match(UpdateChecker::VersionResolver::FAILED_GIT_CLONE_WITH_MIRROR).
                     named_captures.fetch("url")
    raise Dependabot::GitDependenciesNotReachable, dependency_url
  end

  if error.message.match?(UpdateChecker::VersionResolver::FAILED_GIT_CLONE)
    dependency_url = error.message.match(UpdateChecker::VersionResolver::FAILED_GIT_CLONE).
                     named_captures.fetch("url")
    raise Dependabot::GitDependenciesNotReachable, dependency_url
  end

  # NOTE: This matches an error message from composer plugins used to install ACF PRO
  # https://github.com/PhilippBaschke/acf-pro-installer/blob/772cec99c6ef8bc67ba6768419014cc60d141b27/src/ACFProInstaller/Exceptions/MissingKeyException.php#L14
  # https://github.com/pivvenit/acf-pro-installer/blob/f2d4812839ee2c333709b0ad4c6c134e4c25fd6d/src/Exceptions/MissingKeyException.php#L25
  if error.message.start_with?("Could not find a key for ACF PRO") ||
     error.message.start_with?("Could not find a license key for ACF PRO")
    raise MissingEnvironmentVariable, "ACF_PRO_KEY"
  end

  # NOTE: This matches error output from a composer plugin (private-composer-installer):
  # https://github.com/ffraenz/private-composer-installer/blob/8655e3da4e8f99203f13ccca33b9ab953ad30a31/src/Exception/MissingEnvException.php#L22
  if error.message.match?(MISSING_ENV_VAR_REGEX)
    env_var = error.message.match(MISSING_ENV_VAR_REGEX).named_captures.fetch("env_var")
    raise MissingEnvironmentVariable, env_var
  end

  if error.message.start_with?("Unknown downloader type: npm-sign") ||
     error.message.include?("file could not be downloaded") ||
     error.message.include?("configuration does not allow connect")
    raise DependencyFileNotResolvable, error.message
  end

  raise Dependabot::OutOfMemory if error.message.start_with?("Allowed memory size")

  if error.message.include?("403 Forbidden")
    source = error.message.match(%r{https?://(?<source>[^/]+)/}).
             named_captures.fetch("source")
    raise PrivateSourceAuthenticationFailure, source
  end

  # NOTE: This error is raised by composer v1
  if error.message.include?("Argument 1 passed to Composer")
    msg = "One of your Composer plugins is not compatible with the "\
          "latest version of Composer. Please update Composer and "\
          "try running `composer update` to debug further."
    raise DependencyFileNotResolvable, msg
  end

  # NOTE: This error is raised by composer v2 and includes helpful
  # information about which plugins or dependencies are not compatible
  if error.message.include?("Your requirements could not be resolved")
    raise DependencyFileNotResolvable, error.message
  end

  raise error
end
implicit_platform_reqs_satisfiable?(message) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 239
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/file_updater/lockfile_updater.rb, line 475
def initial_platform
  platform_php = parsed_composer_json.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_json.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

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

# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 235
def library?
  parsed_composer_json["type"] == "library"
end
lock_dependencies_being_updated(original_content) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 286
def lock_dependencies_being_updated(original_content)
  dependencies.reduce(original_content) do |content, dep|
    updated_req = dep.version
    next content unless Composer::Version.correct?(updated_req)

    old_req =
      dep.requirements.find { |r| r[:file] == "composer.json" }&.
      fetch(:requirement)

    # When updating a subdep there won't be an old requirement
    next content unless old_req

    regex =
      /
        "#{Regexp.escape(dep.name)}"\s*:\s*
        "#{Regexp.escape(old_req)}"
      /x

    content.gsub(regex) do |declaration|
      declaration.gsub(%("#{old_req}"), %("#{updated_req}"))
    end
  end
end
lock_git_dependencies(content) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 310
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
locked_composer_json_content() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 265
def locked_composer_json_content
  content = updated_composer_json_content
  content = lock_dependencies_being_updated(content)
  content = lock_git_dependencies(content) if @lock_git_deps != false
  content = add_temporary_platform_extensions(content)
  content
end
locked_git_dep_error?(error) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 125
def locked_git_dep_error?(error)
  error.message.start_with?("Could not authenticate against")
end
lockfile() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 513
def lockfile
  @lockfile ||=
    dependency_files.find { |f| f.name == "composer.lock" }
end
parsed_composer_json() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 500
def parsed_composer_json
  @parsed_composer_json ||= JSON.parse(composer_json.content)
end
parsed_lockfile() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 504
def parsed_lockfile
  @parsed_lockfile ||= JSON.parse(lockfile.content)
end
path_dependencies() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 522
def path_dependencies
  @path_dependencies ||=
    dependency_files.select { |f| f.name.end_with?("/composer.json") }
end
php_helper_path() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 448
def php_helper_path
  NativeHelpers.composer_helper_path(composer_version: composer_version)
end
post_process_lockfile(content) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 347
def post_process_lockfile(content)
  content = replace_patches(content)
  content = replace_content_hash(content)
  replace_platform_overrides(content)
end
registry_credentials() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 469
def registry_credentials
  credentials.
    select { |cred| cred.fetch("type") == "composer_repository" }.
    select { |cred| cred["password"] }
end
replace_content_hash(content) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 380
def replace_content_hash(content)
  existing_hash = JSON.parse(content).fetch("content-hash")
  SharedHelpers.in_a_temporary_directory do
    File.write("composer.json", updated_composer_json_content)

    content_hash =
      SharedHelpers.run_helper_subprocess(
        command: "php #{php_helper_path}",
        function: "get_content_hash",
        env: credentials_env,
        args: [Dir.pwd]
      )

    content.gsub(existing_hash, content_hash)
  end
end
replace_patches(updated_content) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 353
def replace_patches(updated_content)
  content = updated_content
  %w(packages packages-dev).each do |package_type|
    JSON.parse(lockfile.content).fetch(package_type).each do |details|
      next unless details["extra"].is_a?(Hash)
      next unless (patches = details.dig("extra", "patches_applied"))

      updated_object = JSON.parse(content)
      updated_object_package =
        updated_object.
        fetch(package_type).
        find { |d| d["name"] == details["name"] }

      next unless updated_object_package

      updated_object_package["extra"] ||= {}
      updated_object_package["extra"]["patches_applied"] = patches

      content =
        JSON.pretty_generate(updated_object, indent: "    ").
        gsub(/\[\n\n\s*\]/, "[]").
        gsub(/\}\z/, "}\n")
    end
  end
  content
end
replace_platform_overrides(content) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 397
def replace_platform_overrides(content)
  original_object = JSON.parse(lockfile.content)
  original_overrides = original_object.fetch("platform-overrides", nil)

  updated_object = JSON.parse(content)

  if original_object.key?("platform-overrides")
    updated_object["platform-overrides"] = original_overrides
  else
    updated_object.delete("platform-overrides")
  end

  JSON.pretty_generate(updated_object, indent: "    ").
    gsub(/\[\n\n\s*\]/, "[]").
    gsub(/\}\z/, "}\n")
end
requirement_valid?(req_string) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 493
def requirement_valid?(req_string)
  Composer::Requirement.requirements_array(req_string)
  true
rescue Gem::Requirement::BadRequirementError
  false
end
run_update_helper() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 92
def run_update_helper
  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: "update",
      env: credentials_env,
      args: [
        Dir.pwd,
        dependency.name,
        dependency.version,
        git_credentials,
        registry_credentials
      ]
    )
  end
end
transitory_failure?(error) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 117
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
update_required_extensions(additional_extensions) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 438
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_composer_json_content() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 110
def updated_composer_json_content
  ManifestUpdater.new(
    dependencies: dependencies,
    manifest: composer_json
  ).updated_manifest_content
end
version_for_reqs(requirements) click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 414
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
  raise "No matching version for #{requirements}!" unless version

  version.to_s
end
write_temporary_dependency_files() click to toggle source
# File lib/dependabot/composer/file_updater/lockfile_updater.rb, line 253
def write_temporary_dependency_files
  path_dependencies.each do |file|
    path = file.name
    FileUtils.mkdir_p(Pathname.new(path).dirname)
    File.write(file.name, file.content)
  end

  File.write("composer.json", locked_composer_json_content)
  File.write("composer.lock", lockfile.content)
  File.write("auth.json", auth_json.content) if auth_json
end