Files
brew/Library/Homebrew/downloadable.rb
T
Mike McQuaid 56f42e0e1d Make tests use public APIs and enforce via RuboCop
- Tests reached into private APIs with `send` and poked internal
  state via `instance_variable_get`/`instance_variable_set`, hiding
  which interfaces specs really exercise and letting visibility rot.
- Make every statically-poked method public and call it directly,
  keeping `public_send` only for dynamically-named public methods.
- Read and write state through public `attr_*` accessors instead of
  instance variable reflection.
- Add `Homebrew/NoSendInTests` and
  `Homebrew/NoInstanceVariableAccessInTests` cops scoped to
  `Library/Homebrew/test` so neither pattern creeps back in.
- Keep rare justified disables, e.g. `Module#remove_const` is
  private core Ruby and raw memoisation state has no accessor.
2026-07-24 19:55:53 +01:00

298 lines
7.7 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "concurrent/set"
require "url"
require "checksum"
require "download_strategy"
require "utils/output"
module Downloadable
include Context
include Utils::Output::Mixin
extend T::Helpers
abstract!
requires_ancestor { Kernel }
# Remembers which files have already been checksum-verified in this process,
# so the same unchanged file is not hashed once per download object that
# references it.
class VerificationCache
include Context
include Utils::Output::Mixin
sig { void }
def initialize
@verified = T.let(Concurrent::Set.new, Concurrent::Set)
end
# Verifies the file against the checksum unless this file, in this
# state, has already been verified against it in this process.
sig { params(filename: Pathname, checksum: T.nilable(Checksum)).void }
def verify(filename, checksum)
key = key_for(filename, checksum)
if key && @verified.include?(key)
odebug "Skipping checksum verification for '#{filename.basename}' (already verified in this run)"
return
end
ohai "Verifying checksum for '#{filename.basename}'" if verbose?
filename.verify_checksum(checksum)
@verified.add(key) if key
end
private
# The size and modification time ensure a file downloaded again to the
# same path (e.g. after `--force` cleared the cache) is verified again.
sig { params(filename: Pathname, checksum: T.nilable(Checksum)).returns(T.nilable(String)) }
def key_for(filename, checksum)
return if checksum.nil?
stat = filename.stat
"#{filename.expand_path}|#{checksum.hexdigest}|#{stat.size}|#{stat.mtime.to_f}"
rescue SystemCallError
nil
end
end
class << self
sig { returns(VerificationCache) }
def verification_cache
@verification_cache ||= T.let(VerificationCache.new, T.nilable(VerificationCache))
end
end
sig { overridable.returns(T.nilable(T.any(String, URL))) }
attr_reader :url
sig { overridable.returns(T.nilable(Checksum)) }
attr_reader :checksum
sig { overridable.returns(T::Array[String]) }
attr_reader :mirrors
sig { overridable.returns(Symbol) }
attr_accessor :phase
sig { void }
def downloading! = (@phase = :downloading)
sig { void }
def downloaded! = (@phase = :downloaded)
sig { void }
def verifying! = (@phase = :verifying)
sig { void }
def verified! = (@phase = :verified)
sig { void }
def extracting! = (@phase = :extracting)
sig { void }
def initialize
@url = T.let(nil, T.nilable(URL))
@checksum = T.let(nil, T.nilable(Checksum))
@mirrors = T.let([], T::Array[String])
@version = T.let(nil, T.nilable(Version))
@download_strategy = T.let(nil, T.nilable(T::Class[AbstractDownloadStrategy]))
@downloader = T.let(nil, T.nilable(AbstractDownloadStrategy))
@download_name = T.let(nil, T.nilable(String))
@phase = T.let(:preparing, Symbol)
end
sig { overridable.params(other: Downloadable).void }
def initialize_dup(other)
super
@checksum = @checksum.dup
@mirrors = @mirrors.dup
@version = @version.dup
end
sig { overridable.returns(T.self_type) }
def freeze
@checksum.freeze
@mirrors.freeze
@version.freeze
super
end
sig { returns(String) }
def download_queue_name = download_name
sig { abstract.returns(String) }
def download_queue_type; end
sig(:final) { returns(String) }
def download_queue_message
"#{download_queue_type} #{download_queue_name}"
end
sig(:final) { returns(T::Boolean) }
def downloaded?
cached_download.exist?
end
sig { overridable.returns(T::Boolean) }
def downloaded_and_valid?
return false unless cached_download.file?
return false if checksum.blank?
with_context(quiet: true) { verify_download_integrity(cached_download) }
true
rescue ChecksumMismatchError
false
end
sig { overridable.returns(Pathname) }
def cached_download
downloader.cached_location
end
sig { overridable.void }
def clear_cache
downloader.clear_cache
end
# Total bytes downloaded if available.
sig { overridable.returns(T.nilable(Integer)) }
def fetched_size
downloader.fetched_size
end
# Total download size if available.
sig { overridable.returns(T.nilable(Integer)) }
def total_size
@total_size ||= T.let(downloader.total_size, T.nilable(Integer))
end
sig { overridable.returns(T.nilable(Version)) }
def version
return @version if @version && !@version.null?
version = determine_url&.version
version unless version&.null?
end
sig { overridable.returns(T::Class[AbstractDownloadStrategy]) }
def download_strategy
@download_strategy ||= T.must(determine_url).download_strategy
end
sig { overridable.returns(AbstractDownloadStrategy) }
def downloader
@downloader ||= begin
primary_url, *mirrors = determine_url_mirrors
raise ArgumentError, "attempted to use a `Downloadable` without a URL!" if primary_url.blank?
download_strategy.new(primary_url, download_name, version,
mirrors:, cache:, **T.must(@url).specs).tap do |downloader|
if downloader.is_a?(CurlDownloadStrategy) &&
AbstractDownloadStrategy.expand_deferred_environment_for?(downloader)
downloader.allow_deferred_environment_expansion!
end
end
end
end
sig {
overridable.params(
verify_download_integrity: T::Boolean,
timeout: T.nilable(T.any(Integer, Float)),
quiet: T::Boolean,
).returns(Pathname)
}
def fetch(verify_download_integrity: true, timeout: nil, quiet: false)
downloading!
cache.mkpath
begin
downloader.quiet! if quiet
downloader.fetch(timeout:)
rescue ErrorDuringExecution, CurlDownloadStrategyError => e
raise DownloadError.new(self, e)
end
downloaded!
download = cached_download
verify_download_integrity(download) if verify_download_integrity
download
end
sig { overridable.params(_download: Pathname, pour: T::Boolean).returns(T::Boolean) }
def stage_from_download_queue?(_download, pour:)
false
end
sig { overridable.params(_download: Pathname, pour: T::Boolean).void }
def stage_from_download_queue(_download, pour:); end
sig { overridable.params(filename: Pathname).void }
def verify_download_integrity(filename)
verifying!
if filename.file?
Downloadable.verification_cache.verify(filename, checksum)
verified!
end
rescue ChecksumMissingError
return if silence_checksum_missing_error?
opoo <<~EOS
Cannot verify integrity of '#{filename.basename}'.
No checksum was provided.
For your reference, the checksum is:
sha256 "#{filename.sha256}"
EOS
end
sig { returns(Integer) }
def hash
[self.class, cached_download].hash
end
sig { params(other: Object).returns(T::Boolean) }
def eql?(other)
return false if self.class != other.class
other = T.cast(other, Downloadable)
cached_download == other.cached_download
end
sig { returns(String) }
def to_s
short_cached_download = cached_download.to_s
.delete_prefix("#{HOMEBREW_CACHE}/downloads/")
"#<#{self.class}: #{short_cached_download}>"
end
private
sig { overridable.returns(String) }
def download_name
@download_name ||= File.basename(determine_url.to_s).freeze
end
sig { overridable.returns(T::Boolean) }
def silence_checksum_missing_error?
false
end
sig { overridable.returns(T.nilable(URL)) }
def determine_url
@url
end
sig { overridable.returns(T::Array[String]) }
def determine_url_mirrors
[determine_url.to_s, *mirrors].uniq
end
sig { overridable.returns(Pathname) }
def cache
HOMEBREW_CACHE
end
end