diff options
Diffstat (limited to 'lib/bundler/checksum.rb')
-rw-r--r-- | lib/bundler/checksum.rb | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/lib/bundler/checksum.rb b/lib/bundler/checksum.rb new file mode 100644 index 0000000000..60ba93417c --- /dev/null +++ b/lib/bundler/checksum.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +module Bundler + class Checksum + ALGO_SEPARATOR = "=" + DEFAULT_ALGORITHM = "sha256" + private_constant :DEFAULT_ALGORITHM + DEFAULT_BLOCK_SIZE = 16_384 + private_constant :DEFAULT_BLOCK_SIZE + + class << self + def from_gem_package(gem_package, algo = DEFAULT_ALGORITHM) + return if Bundler.settings[:disable_checksum_validation] + return unless source = gem_package.instance_variable_get(:@gem) + return unless source.respond_to?(:with_read_io) + + source.with_read_io do |io| + from_gem(io, source.path) + ensure + io.rewind + end + end + + def from_gem(io, pathname, algo = DEFAULT_ALGORITHM) + digest = Bundler::SharedHelpers.digest(algo.upcase).new + buf = String.new(capacity: DEFAULT_BLOCK_SIZE) + digest << io.readpartial(DEFAULT_BLOCK_SIZE, buf) until io.eof? + Checksum.new(algo, digest.hexdigest!, Source.new(:gem, pathname)) + end + + def from_api(digest, source_uri, algo = DEFAULT_ALGORITHM) + return if Bundler.settings[:disable_checksum_validation] + + Checksum.new(algo, to_hexdigest(digest, algo), Source.new(:api, source_uri)) + end + + def from_lock(lock_checksum, lockfile_location) + algo, digest = lock_checksum.strip.split(ALGO_SEPARATOR, 2) + Checksum.new(algo, to_hexdigest(digest, algo), Source.new(:lock, lockfile_location)) + end + + def to_hexdigest(digest, algo = DEFAULT_ALGORITHM) + return digest unless algo == DEFAULT_ALGORITHM + return digest if digest.match?(/\A[0-9a-f]{64}\z/i) + + if digest.match?(%r{\A[-0-9a-z_+/]{43}={0,2}\z}i) + digest = digest.tr("-_", "+/") # fix urlsafe base64 + digest.unpack1("m0").unpack1("H*") + else + raise ArgumentError, "#{digest.inspect} is not a valid SHA256 hex or base64 digest" + end + end + end + + attr_reader :algo, :digest, :sources + + def initialize(algo, digest, source) + @algo = algo + @digest = digest + @sources = [source] + end + + def ==(other) + match?(other) && other.sources == sources + end + + alias_method :eql?, :== + + def same_source?(other) + sources.include?(other.sources.first) + end + + def match?(other) + other.is_a?(self.class) && other.digest == digest && other.algo == algo + end + + def hash + digest.hash + end + + def to_s + "#{to_lock} (from #{sources.first}#{", ..." if sources.size > 1})" + end + + def to_lock + "#{algo}#{ALGO_SEPARATOR}#{digest}" + end + + def merge!(other) + return nil unless match?(other) + + @sources.concat(other.sources).uniq! + self + end + + def formatted_sources + sources.join("\n and ").concat("\n") + end + + def removable? + sources.all?(&:removable?) + end + + def removal_instructions + msg = +"" + i = 1 + sources.each do |source| + msg << " #{i}. #{source.removal}\n" + i += 1 + end + msg << " #{i}. run `bundle install`\n" + end + + def inspect + abbr = "#{algo}#{ALGO_SEPARATOR}#{digest[0, 8]}" + from = "from #{sources.join(" and ")}" + "#<#{self.class}:#{object_id} #{abbr} #{from}>" + end + + class Source + attr_reader :type, :location + + def initialize(type, location) + @type = type + @location = location + end + + def removable? + type == :lock || type == :gem + end + + def ==(other) + other.is_a?(self.class) && other.type == type && other.location == location + end + + # phrased so that the usual string format is grammatically correct + # rake (10.3.2) sha256=abc123 from #{to_s} + def to_s + case type + when :lock + "the lockfile CHECKSUMS at #{location}" + when :gem + "the gem at #{location}" + when :api + "the API at #{location}" + else + "#{location} (#{type})" + end + end + + # A full sentence describing how to remove the checksum + def removal + case type + when :lock + "remove the matching checksum in #{location}" + when :gem + "remove the gem at #{location}" + when :api + "checksums from #{location} cannot be locally modified, you may need to update your sources" + else + "remove #{location} (#{type})" + end + end + end + + class Store + attr_reader :store + protected :store + + def initialize + @store = {} + @store_mutex = Mutex.new + end + + def inspect + "#<#{self.class}:#{object_id} size=#{store.size}>" + end + + # Replace when the new checksum is from the same source. + # The primary purpose is registering checksums from gems where there are + # duplicates of the same gem (according to full_name) in the index. + # + # In particular, this is when 2 gems have two similar platforms, e.g. + # "darwin20" and "darwin-20", both of which resolve to darwin-20. + # In the Index, the later gem replaces the former, so we do that here. + # + # However, if the new checksum is from a different source, we register like normal. + # This ensures a mismatch error where there are multiple top level sources + # that contain the same gem with different checksums. + def replace(spec, checksum) + return unless checksum + + lock_name = spec.name_tuple.lock_name + @store_mutex.synchronize do + existing = fetch_checksum(lock_name, checksum.algo) + if !existing || existing.same_source?(checksum) + store_checksum(lock_name, checksum) + else + merge_checksum(lock_name, checksum, existing) + end + end + end + + def register(spec, checksum) + return unless checksum + + register_checksum(spec.name_tuple.lock_name, checksum) + end + + def merge!(other) + other.store.each do |lock_name, checksums| + checksums.each do |_algo, checksum| + register_checksum(lock_name, checksum) + end + end + end + + def to_lock(spec) + lock_name = spec.name_tuple.lock_name + checksums = @store[lock_name] + if checksums + "#{lock_name} #{checksums.values.map(&:to_lock).sort.join(",")}" + else + lock_name + end + end + + private + + def register_checksum(lock_name, checksum) + @store_mutex.synchronize do + existing = fetch_checksum(lock_name, checksum.algo) + if existing + merge_checksum(lock_name, checksum, existing) + else + store_checksum(lock_name, checksum) + end + end + end + + def merge_checksum(lock_name, checksum, existing) + existing.merge!(checksum) || raise(ChecksumMismatchError.new(lock_name, existing, checksum)) + end + + def store_checksum(lock_name, checksum) + (@store[lock_name] ||= {})[checksum.algo] = checksum + end + + def fetch_checksum(lock_name, algo) + @store[lock_name]&.fetch(algo, nil) + end + end + end +end |