Files
brew/Library/Homebrew/download_strategy/curl_download_strategy.rb
T
Mike McQuaid 204ea7e964 Make tests use public APIs and enable their cops
- 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.
- Enable `Homebrew/NoSendInTests` and
  `Homebrew/NoInstanceVariableAccessInTests` now the test suite is
  clean, 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-27 08:11:46 +01:00

320 lines
12 KiB
Ruby

# typed: strict
# frozen_string_literal: true
# Strategy for downloading files using `curl`.
#
# @api public
class CurlDownloadStrategy < AbstractFileDownloadStrategy
include Utils::Curl
# url, basename, time, file_size, content_type, is_redirection
URLMetadata = T.type_alias { [String, String, T.nilable(Time), T.nilable(Integer), T.nilable(String), T::Boolean] }
sig { returns(T::Array[String]) }
attr_reader :mirrors
sig { params(url: String, name: String, version: T.nilable(T.any(String, Version)), meta: T.untyped).void }
def initialize(url, name, version, **meta)
@try_partial = T.let(true, T::Boolean)
@expand_deferred_environment = T.let(false, T::Boolean)
@mirrors = T.let(meta.fetch(:mirrors, []), T::Array[String])
@file_size = T.let(nil, T.nilable(Integer))
@last_modified = T.let(nil, T.nilable(Time))
# Merge `:header` with `:headers`.
if (header = meta.delete(:header))
meta[:headers] ||= []
meta[:headers] << header
end
super
end
# Download and cache the file at {#cached_location}.
#
# @api public
sig { override.params(timeout: T.nilable(T.any(Float, Integer))).void }
def fetch(timeout: nil)
end_time = Time.now + timeout if timeout
download_lock = DownloadLock.new(temporary_path)
begin
download_lock.lock
urls = [url, *mirrors]
if (domain = Homebrew::EnvConfig.artifact_domain)
artifact_urls = urls.map do |u|
u.sub(%r{^https?://#{GitHubPackages::URL_DOMAIN}/}o, "#{domain.chomp("/")}/")
end
urls = if Homebrew::EnvConfig.artifact_domain_no_fallback?
artifact_urls
else
# Interleave: try artifact domain first, then original for each URL that was rewritten.
combined = []
artifact_urls.zip(urls).each do |artifact_url, original_url|
combined << artifact_url
combined << original_url if original_url != artifact_url
end
combined
end
end
begin
url = T.must(urls.shift)
ohai "Downloading #{url}"
cached_location_valid = cached_location.exist?
resolved_url, _, last_modified, @file_size, content_type, is_redirection = begin
resolve_url_basename_time_file_size(url, timeout: Utils::Timer.remaining!(end_time))
rescue ErrorDuringExecution
raise unless cached_location_valid
end
@last_modified = last_modified
# Caller-supplied headers (e.g. tokens) are no longer valid after a
# redirect to another host, and `Authorization` after any redirect.
if is_redirection
if resolved_url && URI(url).host != URI(resolved_url).host
meta.delete(:headers)
else
meta[:headers]&.delete_if { |header| header.start_with?("Authorization") }
end
end
# The cached location is no longer fresh if either:
# - Last-Modified value is newer than the file's timestamp
# - Content-Length value is different than the file's size
if cached_location_valid && (!content_type.is_a?(String) || !content_type.start_with?("text/"))
if last_modified && last_modified > cached_location.mtime
ohai "Ignoring #{cached_location}",
"Cached modified time #{cached_location.mtime.iso8601} is before " \
"Last-Modified header: #{last_modified.iso8601}"
cached_location_valid = false
end
if @file_size&.nonzero? && @file_size != cached_location.size
ohai "Ignoring #{cached_location}",
"Cached size #{cached_location.size} differs from " \
"Content-Length header: #{@file_size}"
cached_location_valid = false
end
end
if cached_location_valid
puts "Already downloaded: #{cached_location}"
else
begin
_fetch(url:, resolved_url: T.must(resolved_url), timeout: Utils::Timer.remaining!(end_time))
rescue ErrorDuringExecution => e
clean_stderr = strip_progress_bar(Tty.collapse_carriage_returns(e.stderr)).strip
raise CurlDownloadStrategyError.new(url, clean_stderr)
end
cached_location.dirname.mkpath
temporary_path.rename(cached_location.to_s)
end
create_symlink_to_cached_download(cached_location)
rescue CurlDownloadStrategyError
raise if urls.empty?
puts "Trying a mirror..."
retry
rescue Timeout::Error => e
raise Timeout::Error, "Timed out downloading #{self.url}: #{e}"
end
ensure
download_lock.unlock(unlink: true)
end
end
sig { override.returns(T.nilable(Integer)) }
def total_size
@file_size
end
sig { override.void }
def clear_cache
super
rm_rf(temporary_path)
end
sig { params(timeout: T.nilable(T.any(Float, Integer))).returns([T.nilable(Time), Integer]) }
def resolved_time_file_size(timeout: nil)
_, _, time, file_size, = resolve_url_basename_time_file_size(url, timeout:)
[time, T.must(file_size)]
end
sig { void }
def allow_deferred_environment_expansion!
@expand_deferred_environment = true
end
# Curl options to be always passed to curl,
# with raw head calls (`curl --head`) or with actual `fetch`.
sig { returns(T::Array[String]) }
def _curl_args
args = []
args += ["-b", meta.fetch(:cookies).map { |k, v| "#{k}=#{v}" }.join(";")] if meta.key?(:cookies)
args += ["-e", meta.fetch(:referer)] if meta.key?(:referer)
args += ["--user", meta.fetch(:user)] if meta.key?(:user)
if meta.fetch(:headers, []).any? { |header| header.include?(EnvSensitive::DEFERRED_PLACEHOLDER_PREFIX) }
args += ["--max-redirs", "0"]
end
args += expand_deferred_environment_args(meta.fetch(:headers, [])).flat_map { |h| ["--header", h.strip] }
args
end
private
sig { params(timeout: T.nilable(T.any(Float, Integer))).returns([String, String]) }
def resolved_url_and_basename(timeout: nil)
resolved_url, basename, = resolve_url_basename_time_file_size(url, timeout: nil)
[resolved_url, basename]
end
sig { overridable.params(url: String, timeout: T.nilable(T.any(Float, Integer))).returns(URLMetadata) }
def resolve_url_basename_time_file_size(url, timeout: nil)
@resolved_info_cache ||= T.let({}, T.nilable(T::Hash[String, URLMetadata]))
return @resolved_info_cache.fetch(url) if @resolved_info_cache.include?(url)
begin
parsed_output = curl_headers(url.to_s, wanted_headers: ["content-disposition"], timeout:)
rescue ErrorDuringExecution
return [url, parse_basename(url), nil, nil, nil, false]
end
parsed_headers = parsed_output.fetch(:responses).map { |r| r.fetch(:headers) }
final_url = curl_response_follow_redirections(parsed_output.fetch(:responses), url)
content_disposition_parser = Mechanize::HTTP::ContentDispositionParser.new
parse_content_disposition = lambda do |line|
next unless (content_disposition = content_disposition_parser.parse(line.sub(/; *$/, ""), true))
filename = nil
if (filename_with_encoding = content_disposition.parameters["filename*"])
encoding, encoded_filename = filename_with_encoding.split("''", 2)
# If the `filename*` has incorrectly added double quotes, e.g.
# content-disposition: attachment; filename="myapp-1.2.3.pkg"; filename*=UTF-8''"myapp-1.2.3.pkg"
# Then the encoded_filename will come back as the empty string, in which case we should fall back to the
# `filename` parameter.
if encoding.present? && encoded_filename.present?
filename = URI.decode_www_form_component(encoded_filename).encode(encoding)
end
end
filename = content_disposition.filename if filename.blank?
next if filename.blank?
# Servers may include '/' in their Content-Disposition filename header. Take only the basename of this, because:
# - Unpacking code assumes this is a single file - not something living in a subdirectory.
# - Directory traversal attacks are possible without limiting this to just the basename.
File.basename(filename)
end
filenames = parsed_headers.flat_map do |headers|
next [] unless (header = headers["content-disposition"])
[*parse_content_disposition.call("Content-Disposition: #{header}")]
end
time = parsed_headers
.flat_map { |headers| [*headers["last-modified"]] }
.filter_map do |t|
t.match?(/^\d+$/) ? Time.at(t.to_i) : Time.parse(t)
rescue ArgumentError # When `Time.parse` gets a badly formatted date.
nil
end
file_size = parsed_headers
.flat_map { |headers| [*headers["content-length"]&.to_i] }
.last
# Fallback to content-range header if content-length is not available.
# Content-Range format: "bytes start-end/total" or "bytes */total" or "bytes start-end/*"
if file_size.nil? || file_size.zero?
file_size = parsed_headers
.flat_map { |headers| [*headers["content-range"]] }
.filter_map { |range| Integer(range.split("/").last, 10, exception: false) }
.last
end
content_type = parsed_headers
.flat_map { |headers| [*headers["content-type"]] }
.last
is_redirection = url != final_url
basename = filenames.last || parse_basename(final_url, search_query: !is_redirection)
@resolved_info_cache[url] = [final_url, basename, time.last, file_size, content_type, is_redirection]
end
sig {
overridable.params(url: String, resolved_url: String, timeout: T.nilable(T.any(Float, Integer)))
.returns(T.nilable(SystemCommand::Result))
}
def _fetch(url:, resolved_url:, timeout:)
ohai "Downloading from #{resolved_url}" if url != resolved_url
ensure_no_insecure_redirect!(url:, resolved_url:)
_curl_download resolved_url, temporary_path, timeout
end
sig { params(url: String, resolved_url: String).void }
def ensure_no_insecure_redirect!(url:, resolved_url:)
return unless insecure_redirect?(url:, resolved_url:)
error_message = "HTTPS to HTTP redirect detected and `$HOMEBREW_NO_INSECURE_REDIRECT` is set."
$stderr.puts error_message unless quiet?
raise CurlDownloadStrategyError.new(url, error_message)
end
sig {
params(resolved_url: String, to: T.any(Pathname, String), timeout: T.nilable(T.any(Float, Integer)))
.returns(T.nilable(SystemCommand::Result))
}
def _curl_download(resolved_url, to, timeout)
curl_download resolved_url, to:, try_partial: @try_partial, timeout:
end
sig { params(args: T::Array[String]).returns(T::Array[String]) }
def expand_deferred_environment_args(args)
return args unless @expand_deferred_environment
with_context(deferred_environment_expansion: true) do
args.map { |arg| ENV.expand_deferred_environment(arg) }
end
end
sig { returns(T::Hash[Symbol, T.any(String, Symbol)]) }
def _curl_opts
meta.slice(:user_agent)
end
sig { override.params(args: String, options: T.untyped).returns(SystemCommand::Result) }
def curl_output(*args, **options)
super(*_curl_args, *expand_deferred_environment_args(args), **_curl_opts, **options)
end
sig {
override.params(args: String, print_stdout: T.any(T::Boolean, Symbol), options: T.untyped)
.returns(SystemCommand::Result)
}
def curl(*args, print_stdout: true, **options)
options[:connect_timeout] = 15 unless mirrors.empty?
super(*_curl_args, *expand_deferred_environment_args(args), **_curl_opts, **command_output_options, **options)
end
end