class Bundler::CompactIndexClient::Updater

Public Class Methods

new(fetcher) click to toggle source
# File bundler/compact_index_client/updater.rb, line 12
def initialize(fetcher)
  @fetcher = fetcher
end

Public Instance Methods

update(remote_path, local_path, etag_path) click to toggle source
# File bundler/compact_index_client/updater.rb, line 16
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 Instance Methods

append(remote_path, local_path, etag_path) click to toggle source
# File bundler/compact_index_client/updater.rb, line 26
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
byte_sequence(value) click to toggle source

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.

# File bundler/compact_index_client/updater.rb, line 108
def byte_sequence(value)
  return if value.delete_prefix!(":") && !value.delete_suffix!(":")
  return if value.delete_prefix!('"') && !value.delete_suffix!('"')
  value
end
etag_for_request(etag_path) click to toggle source
# File bundler/compact_index_client/updater.rb, line 66
def etag_for_request(etag_path)
  etag_path.read.tap(&:chomp!) if etag_path.file?
end
etag_from_response(response) click to toggle source
# File bundler/compact_index_client/updater.rb, line 80
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
generate_etag(etag_path, file) click to toggle source

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.

# File bundler/compact_index_client/updater.rb, line 74
def generate_etag(etag_path, file)
  etag = file.md5.hexdigest
  CacheFile.write(etag_path, etag)
  etag
end
parse_digests(response) click to toggle source

Unwraps and returns a Hash of digest algorithms and base64 values according to RFC 8941 Structured Field Values for HTTP. www.rfc-editor.org/rfc/rfc8941#name-parsing-a-byte-sequence Ignores unsupported algorithms.

# File bundler/compact_index_client/updater.rb, line 91
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
replace(remote_path, local_path, etag_path) click to toggle source

request without range header to get the full file or a 304 Not Modified

# File bundler/compact_index_client/updater.rb, line 51
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
request_headers(etag, range_start = nil) click to toggle source
# File bundler/compact_index_client/updater.rb, line 59
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