lib/bundler/compact_index_client.rb



# frozen_string_literal: true

require "pathname"
require "set"

module Bundler
  class CompactIndexClient
    # NOTE: MD5 is here not because we expect a server to respond with it, but
    # because we use it to generate the etag on first request during the upgrade
    # to the compact index client that uses opaque etags saved to files.
    # Remove once 2.5.0 has been out for a while.
    SUPPORTED_DIGESTS = { "sha-256" => :SHA256, "md5" => :MD5 }.freeze
    DEBUG_MUTEX = Thread::Mutex.new

    def self.debug
      return unless ENV["DEBUG_COMPACT_INDEX"]
      DEBUG_MUTEX.synchronize { warn("[#{self}] #{yield}") }
    end

    class Error < StandardError; end

    require_relative "compact_index_client/cache"
    require_relative "compact_index_client/cache_file"
    require_relative "compact_index_client/updater"

    attr_reader :directory

    def initialize(directory, fetcher)
      @directory = Pathname.new(directory)
      @updater = Updater.new(fetcher)
      @cache = Cache.new(@directory)
      @endpoints = Set.new
      @info_checksums_by_name = {}
      @parsed_checksums = false
      @mutex = Thread::Mutex.new
    end

    def execution_mode=(block)
      Bundler::CompactIndexClient.debug { "execution_mode=" }
      @endpoints = Set.new

      @execution_mode = block
    end

    # @return [Lambda] A lambda that takes an array of inputs and a block, and
    #         maps the inputs with the block in parallel.
    #
    def execution_mode
      @execution_mode || sequentially
    end

    def sequential_execution_mode!
      self.execution_mode = sequentially
    end

    def sequentially
      @sequentially ||= lambda do |inputs, &blk|
        inputs.map(&blk)
      end
    end

    def names
      Bundler::CompactIndexClient.debug { "/names" }
      update("names", @cache.names_path, @cache.names_etag_path)
      @cache.names
    end

    def versions
      Bundler::CompactIndexClient.debug { "/versions" }
      update("versions", @cache.versions_path, @cache.versions_etag_path)
      versions, @info_checksums_by_name = @cache.versions
      versions
    end

    def dependencies(names)
      Bundler::CompactIndexClient.debug { "dependencies(#{names})" }
      execution_mode.call(names) do |name|
        update_info(name)
        @cache.dependencies(name).map {|d| d.unshift(name) }
      end.flatten(1)
    end

    def update_and_parse_checksums!
      Bundler::CompactIndexClient.debug { "update_and_parse_checksums!" }
      return @info_checksums_by_name if @parsed_checksums
      update("versions", @cache.versions_path, @cache.versions_etag_path)
      @info_checksums_by_name = @cache.checksums
      @parsed_checksums = true
    end

    private

    def update(remote_path, local_path, local_etag_path)
      Bundler::CompactIndexClient.debug { "update(#{local_path}, #{remote_path})" }
      unless synchronize { @endpoints.add?(remote_path) }
        Bundler::CompactIndexClient.debug { "already fetched #{remote_path}" }
        return
      end
      @updater.update(url(remote_path), local_path, local_etag_path)
    end

    def update_info(name)
      Bundler::CompactIndexClient.debug { "update_info(#{name})" }
      path = @cache.info_path(name)
      unless existing = @info_checksums_by_name[name]
        Bundler::CompactIndexClient.debug { "skipping updating info for #{name} since it is missing from versions" }
        return
      end
      checksum = SharedHelpers.checksum_for_file(path, :MD5)
      if checksum == existing
        Bundler::CompactIndexClient.debug { "skipping updating info for #{name} since the versions checksum matches the local checksum" }
        return
      end
      Bundler::CompactIndexClient.debug { "updating info for #{name} since the versions checksum #{existing} != the local checksum #{checksum}" }
      update("info/#{name}", path, @cache.info_etag_path(name))
    end

    def url(path)
      path
    end

    def synchronize
      @mutex.synchronize { yield }
    end
  end
end