Files
brew/Library/Homebrew/resource.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

558 lines
16 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# typed: strict
# frozen_string_literal: true
require "downloadable"
require "formula_creator"
require "mktemp"
require "livecheck"
require "on_system"
require "utils/output"
# Resource is the fundamental representation of an external resource. The
# primary formula download, along with other declared resources, are instances
# of this class.
class Resource
include Downloadable
include FileUtils
include OnSystem::MacOSAndLinux
include Utils::Output::Mixin
Owner = T.type_alias { T.any(Cask::Cask, ::Formula, Resource, SoftwareSpec, Homebrew::FormulaCreator) }
sig { returns(T.nilable(Time)) }
attr_reader :source_modified_time
sig { returns(T::Array[T.any(EmbeddedPatch, ExternalPatch)]) }
attr_reader :patches
sig { returns(T.nilable(Owner)) }
attr_reader :owner
sig { params(checksum: T.nilable(Checksum)).returns(T.nilable(Checksum)) }
attr_writer :checksum
sig { override.returns(T::Class[AbstractDownloadStrategy]) }
def download_strategy
@download_strategy || super
end
sig { params(download_strategy: T.nilable(T::Class[AbstractDownloadStrategy])).void }
attr_writer :download_strategy
# Formula name must be set after the DSL, as we have no access to the
# formula name before initialization of the formula.
sig { returns(T.nilable(String)) }
attr_accessor :name
sig { params(name: T.nilable(String), block: T.nilable(T.proc.bind(Resource).void)).void }
def initialize(name = nil, &block)
super()
# Generally ensure this is synced with `initialize_dup` and `freeze`
# (excluding simple objects like integers & booleans, weak refs like `owner` or permafrozen objects)
@name = name
@source_modified_time = T.let(nil, T.nilable(Time))
@patches = T.let([], T::Array[T.any(EmbeddedPatch, ExternalPatch)])
@owner = T.let(nil, T.nilable(Owner))
@livecheck = T.let(Livecheck.new(self), Livecheck)
@livecheck_defined = T.let(false, T::Boolean)
@insecure = T.let(false, T::Boolean)
instance_eval(&block) if block
end
sig { override.params(other: T.any(Resource, Downloadable)).void }
def initialize_dup(other)
super
@name = @name.dup
@patches = @patches.dup
@livecheck = @livecheck.dup
end
sig { override.returns(T.self_type) }
def freeze
@name.freeze
@patches.freeze
@livecheck.freeze
super
end
sig { params(owner: T.nilable(Owner)).void }
def owner=(owner)
@owner = T.let(owner, T.nilable(Owner))
patches.each { |p| p.owner = owner }
end
sig { override.returns(String) }
def download_queue_type = "Resource"
# Verifies download and unpacks it.
# The block may call `|resource, staging| staging.retain!` to retain the staging
# directory. Subclasses that override stage should implement the tmp
# dir using {Mktemp} so that works with all subtypes.
#
# @api public
sig {
params(
target: T.nilable(T.any(String, Pathname)),
debug_symbols: T::Boolean,
block: T.nilable(T.proc.params(arg0: ResourceStageContext).void),
).void
}
def stage(target = nil, debug_symbols: false, &block)
raise ArgumentError, "Target directory or block is required" if !target && !block_given?
prepare_patches
fetch_patches(skip_downloaded: true)
fetch unless downloaded?
unpack(target, debug_symbols:, &block)
end
sig { void }
def prepare_patches
patches.grep(DATAPatch) { |p| p.path = T.cast(T.cast(T.must(owner), SoftwareSpec).owner, ::Formula).path }
end
sig { params(skip_downloaded: T::Boolean).void }
def fetch_patches(skip_downloaded: false)
external_patches = patches.grep(ExternalPatch)
external_patches.reject!(&:downloaded?) if skip_downloaded
external_patches.each(&:fetch)
end
sig { void }
def apply_patches
return if patches.empty?
ohai "Patching #{name}"
patches.each(&:apply)
end
# If a target is given, unpack there; else unpack to a temp folder.
# If block is given, yield to that block with `|stage|`, where stage
# is a {ResourceStageContext}.
# A target or a block must be given, but not both.
sig {
params(
target: T.nilable(T.any(String, Pathname)),
debug_symbols: T::Boolean,
block: T.nilable(T.proc.params(arg0: ResourceStageContext).void),
).void
}
def unpack(target = nil, debug_symbols: false, &block)
current_working_directory = Pathname.pwd
stage_resource(download_name, debug_symbols:) do |staging|
downloader.stage do
@source_modified_time = downloader.source_modified_time.freeze
apply_patches
if block
yield(ResourceStageContext.new(self, staging))
elsif target
target = Pathname(target)
target = current_working_directory/target if target.relative?
target.install Pathname.pwd.children
end
end
end
end
Partial = Struct.new(:resource, :files)
sig { params(files: T.untyped).returns(Partial) }
def files(*files)
Partial.new(self, files)
end
sig {
override
.params(
verify_download_integrity: T::Boolean,
timeout: T.nilable(T.any(Integer, Float)),
quiet: T::Boolean,
skip_patches: T::Boolean,
).returns(Pathname)
}
def fetch(verify_download_integrity: true, timeout: nil, quiet: false, skip_patches: false)
fetch_patches unless skip_patches
super(verify_download_integrity:, timeout:, quiet:)
end
# {Livecheck} can be used to check for newer versions of the software.
# This method evaluates the DSL specified in the `livecheck` block of the
# {Resource} (if it exists) and sets the instance variables of a {Livecheck}
# object accordingly. This is used by `brew livecheck` to check for newer
# versions of the software.
#
# ### Example
#
# ```ruby
# livecheck do
# url "https://example.com/foo/releases"
# regex /foo-(\d+(?:\.\d+)+)\.tar/
# end
# ```
sig { params(block: T.nilable(T.proc.bind(Livecheck).void)).returns(T.untyped) }
def livecheck(&block)
return @livecheck unless block
@livecheck_defined = true
@livecheck.instance_eval(&block)
end
# Whether a livecheck specification is defined or not.
#
# It returns `true` when a `livecheck` block is present in the {Resource}
# and `false` otherwise.
sig { returns(T::Boolean) }
def livecheck_defined?
@livecheck_defined == true
end
sig { params(val: String).returns(Checksum) }
def sha256(val)
@checksum = Checksum.new(val)
end
sig { override.params(val: T.nilable(String), specs: T.anything).returns(T.nilable(String)) }
def url(val = nil, **specs)
return @url&.to_s if val.nil?
specs = specs.dup
# Don't allow this to be set.
specs.delete(:insecure)
specs[:insecure] = true if @insecure
@url = URL.new(val, specs)
@downloader = nil
@download_strategy = @url.download_strategy
@url.to_s
end
sig { override.params(val: T.nilable(T.any(String, Version))).returns(T.nilable(Version)) }
def version(val = nil)
return super() if val.nil?
@version = case val
when String
val.blank? ? Version::NULL : Version.new(val)
when Version
val
end
end
sig { params(val: String).returns(T::Array[String]) }
def mirror(val)
mirrors << val
end
sig {
params(
strip: T.any(Symbol, String),
src: T.nilable(T.any(Symbol, String)),
block: T.nilable(T.proc.bind(Resource::Patch).void),
).returns(T::Array[T.any(EmbeddedPatch, ExternalPatch)])
}
def patch(strip = :p1, src = nil, &block)
p = ::Patch.create(strip, src, &block)
patches << p
end
sig { returns(T.nilable(T.any(T::Class[AbstractDownloadStrategy], Symbol))) }
def using
@url&.using
end
sig { returns(T::Hash[Symbol, T.untyped]) }
def specs
@url&.specs || {}.freeze
end
protected
sig {
type_parameters(:U)
.params(
prefix: String,
debug_symbols: T::Boolean,
block: T.proc.params(arg0: Mktemp).returns(T.type_parameter(:U)),
).returns(T.type_parameter(:U))
}
def stage_resource(prefix, debug_symbols: false, &block)
Mktemp.new(prefix, retain_in_cache: debug_symbols).run(&block)
end
private
sig { override.returns(String) }
def download_name
owner_name = owner&.name
resource_name = name
if resource_name.nil?
raise "Resource name and owner name are both nil" if owner_name.nil?
owner_name
else
# Removes /s from resource names; this allows Go package names
# to be used as resource names without confusing software that
# interacts with {download_name}, e.g. `github.com/foo/bar`.
escaped_name = resource_name.tr("/", "-")
owner_name ? "#{owner_name}--#{escaped_name}" : escaped_name
end
end
sig { override.returns(T::Array[String]) }
def determine_url_mirrors
extra_urls = []
url = T.must(self.url)
# glibc-bootstrap
if url.start_with?("https://github.com/Homebrew/glibc-bootstrap/releases/download")
if (artifact_domain = Homebrew::EnvConfig.artifact_domain.presence)
artifact_url = url.sub("https://github.com", artifact_domain)
return [artifact_url] if Homebrew::EnvConfig.artifact_domain_no_fallback?
extra_urls << artifact_url
end
if Homebrew::EnvConfig.bottle_domain_custom?
tag, filename = url.split("/").last(2)
extra_urls << "#{Homebrew::EnvConfig.bottle_domain}/glibc-bootstrap/#{tag}/#{filename}"
end
end
# PyPI packages: PEP 503 Simple Repository API <https://peps.python.org/pep-0503>
if Homebrew::EnvConfig.non_default_variable?(:HOMEBREW_PIP_INDEX_URL) &&
(pip_index_url = Homebrew::EnvConfig.pip_index_url.presence)
pip_index_base_url = pip_index_url.chomp("/").chomp("/simple")
%w[https://files.pythonhosted.org https://pypi.org].each do |base_url|
extra_urls << url.sub(base_url, pip_index_base_url) if url.start_with?("#{base_url}/packages")
end
end
[*extra_urls, *super].uniq
end
# A local resource that doesn't need to be downloaded.
class Local < Resource
sig { params(path: String).void }
def initialize(path)
super(File.basename(path))
@downloader = T.let(LocalBottleDownloadStrategy.new(Pathname(path)), LocalBottleDownloadStrategy)
end
end
# A resource for a formula.
class Formula < Resource
sig { override.returns(String) }
def download_queue_type = "Formula"
sig { override.returns(String) }
def download_queue_name = "#{T.must(owner).name} (#{version})"
end
# A resource for a bottle manifest.
class BottleManifest < Resource
class Error < RuntimeError; end
sig { returns(Bottle) }
attr_reader :bottle
sig { params(manifest_annotations: T.nilable(T::Hash[String, String])).void }
attr_writer :manifest_annotations
sig { params(bottle: Bottle).void }
def initialize(bottle)
super("#{bottle.name}_bottle_manifest")
@bottle = bottle
@manifest_annotations = T.let(nil, T.nilable(T::Hash[String, String]))
end
sig { override.void }
def clear_cache
super
@manifest_annotations = nil
end
sig { override.params(_filename: Pathname).void }
def verify_download_integrity(_filename)
# We don't have a checksum, but we can at least try parsing it.
tab
end
sig { override.returns(T::Boolean) }
def downloaded_and_valid?
return false unless downloaded?
with_context(quiet: true) { verify_download_integrity(cached_download) }
true
rescue Error
clear_cache
false
end
sig { returns(T::Hash[String, T.untyped]) }
def tab
tab = manifest_annotations["sh.brew.tab"]
raise Error, "Couldn't find tab from manifest." if tab.blank?
begin
JSON.parse(tab)
rescue JSON::ParserError
raise Error, "Couldn't parse tab JSON."
end
end
sig { returns(T.nilable(Integer)) }
def bottle_size
manifest_annotations["sh.brew.bottle.size"]&.to_i
end
sig { returns(T.nilable(Integer)) }
def installed_size
manifest_annotations["sh.brew.bottle.installed_size"]&.to_i
end
sig { returns(T.nilable(T::Array[String])) }
def path_exec_files
manifest_annotations["sh.brew.path_exec_files"]&.split(",")
end
sig { returns(T.nilable(T::Hash[String, Object])) }
def sbom_supplement
supplement = manifest_annotations["sh.brew.sbom.supplement"]
return if supplement.blank?
parsed_supplement = JSON.parse(supplement)
return unless parsed_supplement.is_a?(Hash)
if (tags = parsed_supplement["tags"]).is_a?(Hash)
tag_supplement = tags[Utils::Bottles.tag.to_s]
return tag_supplement if tag_supplement.is_a?(Hash)
return
end
parsed_supplement
rescue JSON::ParserError
nil
end
sig { override.returns(String) }
def download_queue_type = "Bottle Manifest"
sig { override.returns(String) }
def download_queue_name = "#{bottle.name} (#{bottle.resource.version})"
private
sig { returns(T::Hash[String, String]) }
def manifest_annotations
cached = @manifest_annotations
return cached unless cached.nil?
json = begin
JSON.parse(cached_download.read)
rescue JSON::ParserError
raise Error, "The downloaded GitHub Packages manifest was corrupted or modified (it is not valid JSON): " \
"\n#{cached_download}"
end
manifests = json["manifests"]
raise Error, "Missing 'manifests' section." if manifests.blank?
manifests_annotations = manifests.filter_map { |m| m["annotations"] }
raise Error, "Missing 'annotations' section." if manifests_annotations.blank?
checksum = bottle.resource.checksum
raise "Checksum is nil" if checksum.nil?
bottle_digest = checksum.hexdigest
version = bottle.resource.version
raise "Version is nil" if version.nil?
image_ref = GitHubPackages.version_rebuild(version, bottle.rebuild, bottle.tag.to_s)
manifests_annotation = manifests_annotations.find do |m|
next if m["sh.brew.bottle.digest"] != bottle_digest
m["org.opencontainers.image.ref.name"] == image_ref
end
raise Error, "Couldn't find manifest matching bottle checksum." if manifests_annotation.blank?
@manifest_annotations = manifests_annotation.to_h { |key, value| [key.to_s, value.to_s] }
end
end
# A resource containing a patch.
class Patch < Resource
sig { returns(T::Array[T.any(String, Pathname)]) }
attr_reader :patch_files
sig { params(block: T.nilable(T.proc.bind(Resource::Patch).void)).void }
def initialize(&block)
@patch_files = T.let([], T::Array[T.any(String, Pathname)])
@directory = T.let(nil, T.nilable(T.any(String, Pathname)))
@file = T.let(nil, T.nilable(T.any(String, Pathname)))
@resolves = T.let([], T::Array[String])
@type = T.let(nil, T.nilable(Symbol))
super "patch", &block
end
sig { params(paths: T.any(String, Pathname, T::Array[T.any(String, Pathname)])).void }
def apply(*paths)
@patch_files.concat(paths.flatten)
@patch_files.uniq!
end
sig { params(cves: String).returns(T::Array[String]) }
def resolves(*cves)
return @resolves if cves.empty?
@resolves.concat(cves).uniq!
@resolves
end
sig { params(val: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
def type(val = nil)
return @type if val.nil?
unless ::Patch::TYPES.key?(val)
raise ArgumentError, "Patch type must be one of: #{::Patch::TYPES.keys.map(&:inspect).join(", ")}"
end
@type = val
end
sig { params(val: T.nilable(T.any(String, Pathname))).returns(T.nilable(T.any(String, Pathname))) }
def directory(val = nil)
return @directory if val.nil?
@directory = val
end
sig { params(val: T.nilable(T.any(String, Pathname))).returns(T.nilable(T.any(String, Pathname))) }
def file(val = nil)
return @file if val.nil?
path_string = val.to_s
unless LocalPatch.valid_path?(path_string)
raise ArgumentError, "Patch file must be a relative path within the repository."
end
@file = val
end
sig { override.returns(String) }
def download_queue_type = "Patch"
sig { override.returns(String) }
def download_queue_name
if (last_url_component = url.to_s.split("/").last)
return last_url_component
end
super
end
end
end
require "resource/resource_stage_context"