lib/bundler/compact_index_client/updater.rb



# frozen_string_literal: true

module Bundler
  class CompactIndexClient
    class Updater
      class MismatchedChecksumError < Error
        def initialize(path, message)
          super "The checksum of /#{path} does not match the checksum provided by the server! Something is wrong. #{message}"
        end
      end

      def initialize(fetcher)
        @fetcher = fetcher
      end

      def update(remote_path, local_path, etag_path)
        append(remote_path, local_path, etag_path) || replace(remote_path, local_path, etag_path)
      rescue CacheFile::DigestMismatchError => e
        raise MismatchedChecksumError.new(remote_path, e.message)
      rescue Zlib::GzipFile::Error
        raise Bundler::HTTPError
      end

      private

      def append(remote_path, local_path, etag_path)
        return false unless local_path.file? && local_path.size.nonzero?

        CacheFile.copy(local_path) do |file|
          etag = etag_path.read.tap(&:chomp!) if etag_path.file?
          etag ||= generate_etag(etag_path, file) # Remove this after 2.5.0 has been out for a while.

          # Subtract a byte to ensure the range won't be empty.
          # Avoids 416 (Range Not Satisfiable) responses.
          response = @fetcher.call(remote_path, request_headers(etag, file.size - 1))
          break true if response.is_a?(Gem::Net::HTTPNotModified)

          file.digests = parse_digests(response)
          # server may ignore Range and return the full response
          if response.is_a?(Gem::Net::HTTPPartialContent)
            break false unless file.append(response.body.byteslice(1..-1))
          else
            file.write(response.body)
          end
          CacheFile.write(etag_path, etag_from_response(response))
          true
        end
      end

      # request without range header to get the full file or a 304 Not Modified
      def replace(remote_path, local_path, etag_path)
        etag = etag_path.read.tap(&:chomp!) if etag_path.file?
        response = @fetcher.call(remote_path, request_headers(etag))
        return true if response.is_a?(Gem::Net::HTTPNotModified)
        CacheFile.write(local_path, response.body, parse_digests(response))
        CacheFile.write(etag_path, etag_from_response(response))
      end

      def request_headers(etag, range_start = nil)
        headers = {}
        headers["Range"] = "bytes=#{range_start}-" if range_start
        headers["If-None-Match"] = %("#{etag}") if etag
        headers
      end

      def etag_for_request(etag_path)
        etag_path.read.tap(&:chomp!) if etag_path.file?
      end

      # When first releasing this opaque etag feature, we want to generate the old MD5 etag
      # based on the content of the file. After that it will always use the saved opaque etag.
      # This transparently saves existing users with good caches from updating a bunch of files.
      # Remove this behavior after 2.5.0 has been out for a while.
      def generate_etag(etag_path, file)
        etag = file.md5.hexdigest
        CacheFile.write(etag_path, etag)
        etag
      end

      def etag_from_response(response)
        return unless response["ETag"]
        etag = response["ETag"].delete_prefix("W/")
        return if etag.delete_prefix!('"') && !etag.delete_suffix!('"')
        etag
      end

      # Unwraps and returns a Hash of digest algorithms and base64 values
      # according to RFC 8941 Structured Field Values for HTTP.
      # https://www.rfc-editor.org/rfc/rfc8941#name-parsing-a-byte-sequence
      # Ignores unsupported algorithms.
      def parse_digests(response)
        return unless header = response["Repr-Digest"] || response["Digest"]
        digests = {}
        header.split(",") do |param|
          algorithm, value = param.split("=", 2)
          algorithm.strip!
          algorithm.downcase!
          next unless SUPPORTED_DIGESTS.key?(algorithm)
          next unless value = byte_sequence(value)
          digests[algorithm] = value
        end
        digests.empty? ? nil : digests
      end

      # Unwrap surrounding colons (byte sequence)
      # The wrapping characters must be matched or we return nil.
      # Also handles quotes because right now rubygems.org sends them.
      def byte_sequence(value)
        return if value.delete_prefix!(":") && !value.delete_suffix!(":")
        return if value.delete_prefix!('"') && !value.delete_suffix!('"')
        value
      end
    end
  end
end