class Bundler::CompactIndexClient::Updater

def append(remote_path, local_path, etag_path)

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

def byte_sequence(value)

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

def etag_for_request(etag_path)

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

def etag_from_response(response)

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

def generate_etag(etag_path, file)

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

def initialize(fetcher)

def initialize(fetcher)
  @fetcher = fetcher
end

def parse_digests(response)

Ignores unsupported algorithms.
https://www.rfc-editor.org/rfc/rfc8941#name-parsing-a-byte-sequence
according to RFC 8941 Structured Field Values for HTTP.
Unwraps and returns a Hash of digest algorithms and base64 values
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

def replace(remote_path, local_path, etag_path)

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)

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 update(remote_path, local_path, etag_path)

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