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

942 lines
32 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "cask/cache"
require "cask/cask"
require "uri"
require "utils/curl"
require "utils/output"
require "utils/path"
require "extend/hash/keys"
require "extend/ENV/sensitive"
require "api"
require "trust"
module Cask
# Loads a cask from various sources.
module CaskLoader
extend Context
extend ::Utils::Output::Mixin
ALLOWED_URL_SCHEMES = %w[file].freeze
private_constant :ALLOWED_URL_SCHEMES
module ILoader
extend T::Helpers
include ::Utils::Output::Mixin
interface!
sig { abstract.params(config: T.nilable(Config)).returns(Cask) }
def load(config:); end
end
# Loads a cask from a string.
class AbstractContentLoader
include ILoader
extend T::Helpers
abstract!
sig { returns(String) }
attr_reader :content
sig { overridable.returns(T.nilable(Tap)) }
attr_reader :tap
sig { void }
def initialize
@content = T.let("", String)
@tap = T.let(nil, T.nilable(Tap))
@config = T.let(nil, T.nilable(Config))
end
private
sig {
overridable.params(
header_token: String,
options: T.untyped,
block: T.nilable(T.proc.bind(DSL).void),
).returns(Cask)
}
def cask(header_token, **options, &block)
Cask.new(header_token, source: content, tap:, **options, config: @config, &block)
end
end
# Loads a cask from a string.
class FromContentLoader < AbstractContentLoader
sig {
params(ref: T.any(Pathname, String, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(T.attached_class))
}
def self.try_new(ref, warn: false)
return if ref.is_a?(Cask)
content = ref.to_str
# Cache compiled regex
@regex ||= T.let(
begin
token = /(?:"[^"]*"|'[^']*')/
curly = /\(\s*#{token.source}\s*\)\s*\{.*\}/
do_end = /\s+#{token.source}\s+do(?:\s*;\s*|\s+).*end/
/\A\s*cask(?:#{curly.source}|#{do_end.source})\s*\Z/m
end,
T.nilable(Regexp),
)
return unless content.match?(@regex)
new(content)
end
sig { params(content: String, tap: Tap).void }
def initialize(content, tap: T.unsafe(nil))
super()
@content = T.let(content.dup.force_encoding("UTF-8"), String)
@tap = T.let(tap, T.nilable(Tap))
end
sig { override.params(config: T.nilable(Config)).returns(Cask) }
def load(config:)
@config = config
ENV.clear_sensitive_environment_for_eval! do
instance_eval(content, __FILE__, __LINE__)
end
end
end
# Loads a cask from a path.
class FromPathLoader < AbstractContentLoader
sig {
overridable.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(T.attached_class))
}
def self.try_new(ref, warn: false)
path = case ref
when String
Pathname(ref)
when Pathname
ref
else
return
end
return unless path.expand_path.exist?
return if invalid_path?(path)
return unless ::Utils::Path.loadable_package_path?(path, :cask)
new(path)
end
sig { params(pathname: Pathname, valid_extnames: T::Array[String]).returns(T::Boolean) }
def self.invalid_path?(pathname, valid_extnames: %w[.rb .json])
return true if valid_extnames.exclude?(pathname.extname)
@invalid_basenames ||= T.let(%w[INSTALL_RECEIPT.json sbom.spdx.json].freeze, T.nilable(T::Array[String]))
@invalid_basenames.include?(pathname.basename.to_s)
end
sig { returns(String) }
attr_reader :token
sig { returns(Pathname) }
attr_reader :path
sig { params(from_installed_caskfile: T::Boolean).void }
attr_writer :from_installed_caskfile
sig { params(path: T.any(Pathname, String), token: String).void }
def initialize(path, token: T.unsafe(nil))
super()
path = Pathname(path).expand_path
@token = T.let(CaskLoader.token_from_path(path), String)
@path = T.let(path, Pathname)
@tap = T.let(Tap.from_path(path) || Homebrew::API.tap_from_source_download(path), T.nilable(Tap))
@from_installed_caskfile = T.let(false, T::Boolean)
@api_fallback = T.let(true, T::Boolean)
end
sig { override.params(config: T.nilable(Config)).returns(Cask) }
def load(config:)
raise CaskUnavailableError.new(token, "'#{path}' does not exist.") unless path.exist?
raise CaskUnavailableError.new(token, "'#{path}' is not readable.") unless path.readable?
raise CaskUnavailableError.new(token, "'#{path}' is not a file.") unless path.file?
Homebrew::Trust.require_trusted_cask!(token, path)
@content = path.read(encoding: "UTF-8")
@config = config
if !self.class.invalid_path?(path, valid_extnames: %w[.json]) &&
(from_json = JSON.parse(@content)) &&
from_json.is_a?(Hash) &&
(@from_installed_caskfile || from_json.present?)
begin
from_internal_json = path.to_s.end_with?(".internal.json")
return FromAPILoader.new(
token,
from_json:,
path:,
from_installed_caskfile: @from_installed_caskfile,
from_internal_json:,
api_fallback: @api_fallback,
).load(config:)
rescue CaskInvalidError => e
if @from_installed_caskfile
error = CaskUnreadableError.new(token, e.reason)
error.set_backtrace e.backtrace
raise error
end
raise
end
end
begin
ENV.clear_sensitive_environment_for_eval! do
instance_eval(content, path.to_s).tap do |cask|
raise CaskUnreadableError.new(token, "'#{path}' does not contain a cask.") unless cask.is_a?(Cask)
end
end
rescue NameError, ArgumentError, ScriptError => e
error = CaskUnreadableError.new(token, e.message)
error.set_backtrace e.backtrace
raise error
rescue CaskInvalidError => e # e.g. NoMethodError from removed DSL methods, wrapped
# as CaskInvalidError by Cask#refresh before reaching here.
if @from_installed_caskfile
error = CaskUnreadableError.new(token, e.reason)
error.set_backtrace e.backtrace
raise error
end
raise
end
end
private
sig {
override.params(
header_token: String,
options: T.untyped,
block: T.nilable(T.proc.bind(DSL).void),
).returns(Cask)
}
def cask(header_token, **options, &block)
raise CaskTokenMismatchError.new(token, header_token) if token != header_token
super(header_token, **options, sourcefile_path: path, &block)
end
end
# Loads a cask from a URI.
class FromURILoader < FromPathLoader
sig {
override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(T.attached_class))
}
def self.try_new(ref, warn: false)
return if Homebrew::EnvConfig.forbid_packages_from_paths?
# Cache compiled regex
@uri_regex ||= T.let(
begin
uri_regex = ::URI::RFC2396_PARSER.make_regexp
Regexp.new("\\A#{uri_regex.source}\\Z", uri_regex.options)
end,
T.nilable(Regexp),
)
uri = ref.to_s
return unless uri.match?(@uri_regex)
uri = URI(uri)
return unless uri.path
new(uri)
end
sig { returns(URI::Generic) }
attr_reader :url
sig { returns(String) }
attr_reader :name
sig { params(url: T.any(URI::Generic, String)).void }
def initialize(url)
@url = T.let(URI(url), URI::Generic)
url_path = @url.path
raise "unexpected nil url.path" unless url_path
@name = T.let(File.basename(url_path), String)
super Cache.path/name
end
sig { override.params(config: T.nilable(Config)).returns(Cask) }
def load(config:)
path.dirname.mkpath
if ALLOWED_URL_SCHEMES.exclude?(url.scheme)
raise UnsupportedInstallationMethod,
"Non-checksummed download of #{name} formula file from an arbitrary URL is unsupported! " \
"`brew version-install` to install a formula file from your own custom tap " \
"instead."
end
begin
ohai "Downloading #{url}"
::Utils::Curl.curl_download url.to_s, to: path
rescue ErrorDuringExecution
raise CaskUnavailableError.new(token, "Failed to download #{Formatter.url(url)}.")
end
super
end
end
# Loads a cask from a specific tap.
class FromTapLoader < FromPathLoader
sig { override.returns(Tap) }
attr_reader :tap
sig {
override(allow_incompatible: true) # rubocop:todo Sorbet/AllowIncompatibleOverride
.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(T.any(T.attached_class, FromAPILoader)))
}
def self.try_new(ref, warn: false)
ref = ref.to_s
return unless (token_tap_type = CaskLoader.tap_cask_token_type(ref, warn:))
loader_from_token_tap_type(token_tap_type)
end
sig {
params(token_tap_type: [String, Tap, T.nilable(Symbol)])
.returns(T.nilable(T.any(T.attached_class, FromAPILoader)))
}
def self.loader_from_token_tap_type(token_tap_type)
token, tap, type = token_tap_type
if type == :migration && tap.core_cask_tap? && (loader = FromAPILoader.try_new(token))
loader
else
new("#{tap}/#{token}")
end
end
sig { params(tapped_token: String).void }
def initialize(tapped_token)
tap_with_token = Tap.with_cask_token(tapped_token)
raise "unexpected nil Tap.with_cask_token" unless tap_with_token
tap, token = tap_with_token
cask = CaskLoader.find_cask_in_tap(token, tap)
super cask
@tap = T.let(tap, Tap)
end
sig { override.params(config: T.nilable(Config)).returns(Cask) }
def load(config:)
raise TapCaskUnavailableError.new(tap, token) unless tap.installed?
super
end
end
# Loads a cask from an existing {Cask} instance.
class FromInstanceLoader
include ILoader
sig {
params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(FromInstanceLoader))
}
def self.try_new(ref, warn: false)
new(ref) if ref.is_a?(Cask)
end
sig { params(cask: Cask).void }
def initialize(cask)
@cask = cask
end
# This is a false positive incompatibililty warning, due to Kernel#load being overridden.
sig { override(allow_incompatible: true).params(config: T.nilable(Config)).returns(Cask) } # rubocop:disable Sorbet/AllowIncompatibleOverride
def load(config:)
@cask
end
end
# Loads a cask from the JSON API.
class FromAPILoader
include ILoader
sig { returns(String) }
attr_reader :token
sig { returns(Pathname) }
attr_reader :path
sig { returns(T.nilable(T::Hash[String, T.untyped])) }
attr_reader :from_json
sig {
params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(FromAPILoader))
}
def self.try_new(ref, warn: false)
return if Homebrew::EnvConfig.no_install_from_api?
return unless ref.is_a?(String)
return unless (token = ref[HOMEBREW_DEFAULT_TAP_CASK_REGEX, :token])
if !Homebrew::API.cask_token?(token) &&
!Homebrew::API.cask_renames.key?(token)
return
end
ref = "#{CoreCaskTap.instance}/#{token}"
token, tap, = CaskLoader.tap_cask_token_type(ref, warn:)
new("#{tap}/#{token}")
end
sig {
params(
token: String,
from_json: T.nilable(T::Hash[String, T.untyped]),
path: T.nilable(Pathname),
from_installed_caskfile: T::Boolean,
from_internal_json: T::Boolean,
api_fallback: T::Boolean,
).void
}
def initialize(token, from_json: T.unsafe(nil), path: nil, from_installed_caskfile: false,
from_internal_json: false, api_fallback: true)
@token = T.let(token.sub(%r{^homebrew/(?:homebrew-)?cask/}i, ""), String)
@sourcefile_path = T.let(
if path
path
elsif from_json
from_internal_json ? Homebrew::API::Internal.cached_packages_json_file_path : Homebrew::API::Cask.cached_json_file_path
else
Homebrew::API.cached_cask_json_file_path
end,
Pathname,
)
@path = T.let(path || CaskLoader.default_path(@token), Pathname)
@from_json = from_json
@from_installed_caskfile = from_installed_caskfile
@from_internal_json = from_internal_json
@api_fallback = api_fallback
end
# This is a false positive incompatibililty warning, due to Kernel#load being overridden.
sig { override(allow_incompatible: true).params(config: T.nilable(Config)).returns(Cask) } # rubocop:disable Sorbet/AllowIncompatibleOverride
def load(config:)
if (api_source = from_json)
if @from_internal_json
load_from_internal_json(config:, api_source:)
else
load_from_json(config:, api_source:)
end
else
load_from_internal_api(config:)
end
end
private
sig { params(config: T.nilable(Config)).returns(Cask) }
def load_from_internal_api(config:)
cask_struct = Homebrew::API::Internal.cask_struct(token)
api_source = Homebrew::API::Internal.cask_hashes.fetch(token)
tap_git_head = Homebrew::API::Internal.cask_tap_git_head
load_from_struct(config:, cask_struct:, api_source:, tap_git_head:, internal_api: true)
end
sig { params(config: T.nilable(Config), api_source: T::Hash[String, T.untyped]).returns(Cask) }
def load_from_json(config:, api_source:)
if @from_installed_caskfile
api_source = api_source.dup
api_source["version"] = api_source["version"].presence
api_source["version"] ||= @sourcefile_path.dirname.dirname.dirname.basename.to_s.presence
if api_source["version"].nil? || api_source["artifacts"].nil?
installed_tab = CaskLoader.load_installed_tab(token)
api_source["version"] ||= installed_tab.version.presence
api_source["artifacts"] ||= CaskLoader.resolve_installed_artifacts(
token, installed_tab.uninstall_artifacts, tap: installed_tab.tap, api_fallback: @api_fallback
)
end
end
tap_git_head = api_source["tap_git_head"]
cask_struct = Homebrew::API::Cask::CaskStructGenerator.generate_cask_struct_hash(
api_source, ignore_types: @from_installed_caskfile
)
load_from_struct(config:, cask_struct:, api_source:, tap_git_head:)
end
sig { params(config: T.nilable(Config), api_source: T::Hash[String, T.untyped]).returns(Cask) }
def load_from_internal_json(config:, api_source:)
api_source = api_source.dup
tap_git_head = api_source.delete("tap_git_head")
cask_struct = Homebrew::API::CaskStruct.deserialize(api_source)
load_from_struct(config:, cask_struct:, api_source:, tap_git_head:, internal_api: true)
end
sig {
params(
config: T.nilable(Config),
cask_struct: Homebrew::API::CaskStruct,
api_source: T::Hash[String, T.untyped],
tap_git_head: T.nilable(String),
internal_api: T::Boolean,
).returns(Cask)
}
def load_from_struct(config:, cask_struct:, api_source:, tap_git_head:, internal_api: false)
cask_options = {
loaded_from_api: true,
loaded_from_internal_api: internal_api,
api_source:,
sourcefile_path: @sourcefile_path,
source: JSON.pretty_generate(api_source),
config:,
loader: self,
}
if (tap_string = cask_struct.tap_string)
cask_options[:tap] = Tap.fetch(tap_string)
end
api_cask = Cask.new(token, **cask_options) do
localised_cask_struct = if cask_struct.language_variations.empty?
cask_struct
else
cask_struct.localise(cask.config.languages)
end
version localised_cask_struct.version
sha256 localised_cask_struct.sha256
url(*localised_cask_struct.url_args, **localised_cask_struct.url_kwargs)
localised_cask_struct.names.each do |cask_name|
name cask_name
end
desc localised_cask_struct.desc if localised_cask_struct.desc?
homepage localised_cask_struct.homepage if localised_cask_struct.homepage?
deprecate!(**localised_cask_struct.deprecate_args) if localised_cask_struct.deprecate?
disable!(**localised_cask_struct.disable_args) if localised_cask_struct.disable?
auto_updates localised_cask_struct.auto_updates if localised_cask_struct.auto_updates?
conflicts_with(**localised_cask_struct.conflicts_with_args) if localised_cask_struct.conflicts?
localised_cask_struct.renames.each do |from, to|
rename from, to
end
if localised_cask_struct.depends_on?
args = localised_cask_struct.depends_on_args
begin
depends_on(**args)
rescue MacOSVersion::Error => e
odebug "Ignored invalid macOS version dependency in cask '#{token}': #{args.inspect} (#{e.message})"
nil
end
end
if localised_cask_struct.container?
container(nested: localised_cask_struct.container_args[:nested],
type: localised_cask_struct.container_args[:type])
end
localised_cask_struct.artifacts(appdir:).each do |key, args, kwargs, block|
send(key, *args, **kwargs, &block)
end
caveats T.must(localised_cask_struct.caveats(appdir:)) if localised_cask_struct.caveats?
if localised_cask_struct.caveats_rosetta
caveats do
# Dynamically defined via `caveat :requires_rosetta` — Sorbet can't resolve it.
T.unsafe(self).requires_rosetta
end
end
end
api_cask.populate_from_api!(cask_struct, tap_git_head:)
api_cask
end
end
# Loader which tries loading casks from tap paths, failing
# if the same token exists in multiple taps.
class FromNameLoader < FromTapLoader
extend ::Utils::Output::Mixin
sig {
override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(T.any(T.attached_class, FromAPILoader)))
}
def self.try_new(ref, warn: false)
return unless ref.is_a?(String)
return unless ref.match?(/\A#{HOMEBREW_TAP_CASK_TOKEN_REGEX}\Z/o)
token = ref.downcase
# If it exists in the default tap, never treat it as ambiguous with another tap.
if (core_cask_tap = CoreCaskTap.instance).installed? && (token_tap_type = CaskLoader.tap_cask_token_type(
"#{core_cask_tap}/#{token}", warn: false
))
migrated_token, migrated_tap, type = token_tap_type
if warn && [:rename, :migration].include?(type) &&
!(type == :migration && migrated_tap.core_tap?)
opoo "Cask #{token} was renamed to " \
"#{migrated_tap.core_cask_tap? ? migrated_token : "#{migrated_tap}/#{migrated_token}"}."
end
if (core_cask_loader = loader_from_token_tap_type(token_tap_type))&.path&.exist?
return core_cask_loader
end
end
loaders = Tap.select { |tap| tap.installed? && !tap.core_cask_tap? }
.filter_map { |tap| super("#{tap}/#{token}", warn:) }
.uniq(&:path)
.select { |loader| loader.is_a?(FromAPILoader) || loader.path.exist? }
case loaders.count
when 1
loaders.first
when 2..Float::INFINITY
raise TapCaskAmbiguityError.new(token, loaders)
end
end
end
# Loader which loads a cask from the installed cask file.
class FromInstalledPathLoader < FromPathLoader
sig {
override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean,
api_fallback: T::Boolean).returns(T.nilable(T.attached_class))
}
def self.try_new(ref, warn: false, api_fallback: true)
token = if ref.is_a?(String)
ref
elsif ref.is_a?(Pathname)
CaskLoader.token_from_path(ref)
end
return unless token
possible_installed_cask = Cask.new(token)
return unless (installed_caskfile = possible_installed_cask.installed_caskfile)
new(installed_caskfile, api_fallback:)
end
sig { params(path: T.any(Pathname, String), token: String, api_fallback: T::Boolean).void }
def initialize(path, token: "", api_fallback: true)
super(path, token:)
installed_tap = Cask.new(@token).tab.tap
@tap = installed_tap if installed_tap
@from_installed_caskfile = T.let(true, T::Boolean)
@api_fallback = api_fallback
end
end
# Pseudo-loader which raises an error when trying to load the corresponding cask.
class NullLoader < FromPathLoader
sig {
override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean)
.returns(T.nilable(T.attached_class))
}
def self.try_new(ref, warn: false)
return if ref.is_a?(Cask)
return if ref.is_a?(URI::Generic)
new(ref)
end
sig { params(ref: T.any(String, Pathname)).void }
def initialize(ref)
token = File.basename(ref, ".rb")
super CaskLoader.default_path(token)
end
sig { override.params(config: T.nilable(Config)).returns(Cask) }
def load(config:)
raise CaskUnavailableError.new(token, "No Cask with this name exists.")
end
end
# NOTE: Using `WithoutRuntime` to avoid Sorbet wrapping this method,
# which would interfere with RSpec mocking of this class method.
T::Sig::WithoutRuntime.sig { params(ref: T.any(String, Pathname, Cask, URI::Generic)).returns(Pathname) }
def self.path(ref)
T.cast(self.for(ref, need_path: true), T.any(FromAPILoader, FromPathLoader)).path
end
# NOTE: Using `WithoutRuntime` to avoid Sorbet wrapping this method,
# which would interfere with RSpec mocking of this class method.
T::Sig::WithoutRuntime.sig {
params(ref: T.any(String, Symbol, Pathname, Cask, URI::Generic), config: T.nilable(Config),
warn: T::Boolean).returns(Cask)
}
def self.load(ref, config: nil, warn: true)
normalized_ref = ref.is_a?(Symbol) ? ref.to_s : ref
self.for(normalized_ref, warn:).load(config:)
end
sig { params(tapped_token: String, warn: T::Boolean).returns(T.nilable([String, Tap, T.nilable(Symbol)])) }
def self.tap_cask_token_type(tapped_token, warn:)
return unless (tap_with_token = Tap.with_cask_token(tapped_token))
tap, token = tap_with_token
type = nil
if (new_token = tap.cask_renames[token].presence)
old_token = tap.core_cask_tap? ? token : tapped_token
token = new_token
new_token = tap.core_cask_tap? ? token : "#{tap}/#{token}"
type = :rename
elsif (new_tap_name = tap.tap_migrations[token].presence)
new_tap, new_token = Tap.with_cask_token(new_tap_name)
unless new_tap
if new_tap_name.include?("/")
new_tap = Tap.fetch(new_tap_name)
new_token = token
else
new_tap = tap
new_token = new_tap_name
end
end
new_tapped_token = "#{new_tap}/#{new_token}"
if tapped_token != new_tapped_token
old_token = tap.core_cask_tap? ? token : tapped_token
return unless (token_tap_type = tap_cask_token_type(new_tapped_token, warn: false))
token, tap, = token_tap_type
new_token = new_tap.core_cask_tap? ? token : "#{tap}/#{token}"
type = :migration
end
end
if warn && old_token && new_token
destination_exists = find_cask_in_tap(token, tap).exist? ||
(tap.core_cask_tap? && !Homebrew::EnvConfig.no_install_from_api? &&
Homebrew::API.cask_token?(token))
opoo "Cask #{old_token} was renamed to #{new_token}." if destination_exists
end
[token, tap, type]
end
# NOTE: Using `WithoutRuntime` to avoid Sorbet wrapping this method,
# which would interfere with RSpec mocking of this class method.
T::Sig::WithoutRuntime.sig {
params(ref: T.any(String, Pathname, Cask, URI::Generic), need_path: T::Boolean, warn: T::Boolean)
.returns(ILoader)
}
def self.for(ref, need_path: false, warn: true)
[
FromInstanceLoader,
FromContentLoader,
FromURILoader,
FromAPILoader,
FromTapLoader,
FromNameLoader,
FromPathLoader,
FromInstalledPathLoader,
NullLoader,
].each do |loader_class|
if (loader = loader_class.try_new(ref, warn:))
$stderr.puts "#{$PROGRAM_NAME} (#{loader.class}): loading #{ref}" if verbose? && debug?
return loader
end
end
raise CaskError, "No cask loader found for #{ref.inspect}"
end
sig { params(ref: String, config: T.nilable(Config), warn: T::Boolean).returns(Cask) }
def self.load_prefer_installed(ref, config: nil, warn: true)
tap, token = Tap.with_cask_token(ref)
token ||= ref
tap ||= Cask.new(ref).tab.tap
if tap.nil?
self.load(token, config:, warn:)
else
begin
self.load("#{tap}/#{token}", config:, warn:)
rescue CaskUnavailableError
# cask may be migrated to different tap. Try to search in all taps.
self.load(token, config:, warn:)
end
end
end
sig {
params(path: Pathname, config: T.nilable(Config), warn: T::Boolean, api_fallback: T::Boolean)
.returns(Cask)
}
def self.load_from_installed_caskfile(path, config: nil, warn: true, api_fallback: true)
loader = FromInstalledPathLoader.try_new(path, warn:, api_fallback:)
loader ||= NullLoader.new(path)
loader.load(config:)
end
sig { params(path: Pathname).returns(String) }
def self.token_from_path(path)
path.basename(path.extname).basename(".internal").to_s
end
# Legacy `.internal.json` files contain full API data rather than the compact installed JSON format.
sig { params(path: Pathname).returns(T::Boolean) }
def self.installed_json_caskfile?(path)
path.extname == ".json" && !path.basename.to_s.end_with?(".internal.json")
end
sig { params(path: Pathname).returns(T.nilable(T::Hash[String, T.untyped])) }
def self.load_installed_json(path)
return unless installed_json_caskfile?(path)
json = JSON.parse(path.read)
json if json.is_a?(Hash)
rescue JSON::ParserError
nil
end
sig { params(cask_or_token: T.any(Cask, String)).returns(Tab) }
def self.load_installed_tab(cask_or_token)
cask = if cask_or_token.is_a?(Cask)
cask_or_token
else
Cask.new(cask_or_token)
end
cask.tab
rescue JSON::ParserError, NoMethodError, TypeError
Tab.empty
end
sig {
params(
token: String,
artifacts: T.nilable(T::Array[T::Hash[T.any(String, Symbol), T.anything]]),
tap: T.nilable(Tap),
api_fallback: T::Boolean,
).returns(T::Array[T::Hash[T.any(String, Symbol), T.anything]])
}
def self.resolve_installed_artifacts(token, artifacts, tap: nil, api_fallback: true)
artifacts = artifacts.presence
return artifacts if artifacts
return [] unless api_fallback
artifacts ||= begin
tap_loader = (FromNameLoader.try_new(token, warn: false) if tap.nil? && FromAPILoader.try_new(token).nil?)
if tap && !tap.core_cask_tap?
load("#{tap}/#{token}", warn: false).artifacts_list(uninstall_only: true)
elsif tap_loader
tap_loader.load(config: nil).artifacts_list(uninstall_only: true)
end
rescue CaskError, MethodDeprecatedError, JSON::ParserError, ErrorDuringExecution, SystemExit
nil
end
# API fetch failures must not abort best-effort installed metadata recovery. Skip the
# per-cask endpoint only when the token is definitively absent from the current API;
# a membership-check failure is treated as unknown so recovery still tries the endpoint.
artifacts ||= begin
definitely_absent = begin
!Homebrew::API.cask_token?(token)
rescue ErrorDuringExecution, SystemExit
false
end
Homebrew::API::Cask.cask_json(token)["artifacts"] unless definitely_absent
rescue ErrorDuringExecution, SystemExit
nil
end
artifacts ||= []
artifacts
end
sig {
params(
path: Pathname,
tab: T.nilable(Tab),
fallback_cask: T.nilable(Cask),
config: T.nilable(Config),
).returns(T.nilable(Cask))
}
def self.recover_from_installed_caskfile(path, tab: nil, fallback_cask: nil, config: nil)
# Only installed metadata has the versioned path layout used to rebuild the cask below.
return if path.dirname.basename.to_s != "Casks"
# Read any usable receipt, while retaining the current cask as a fallback for missing receipt data.
token = token_from_path(path)
tab ||= load_installed_tab(fallback_cask || token)
# Ruby uninstall flight blocks cannot be represented by installed JSON and must not be approximated.
return if tab.uninstall_flight_blocks
return if fallback_cask&.uninstall_flight_blocks?
# Prefer exact receipt artifacts, then the current cask and finally the current source definition.
artifacts = tab.uninstall_artifacts.presence
artifacts ||= fallback_cask.artifacts_list(uninstall_only: true) if fallback_cask
artifacts ||= resolve_installed_artifacts(token, nil, tap: tab.tap)
# Rebuild the installed version from its metadata directory and retain current source path information.
api_source = {
"version" => path.dirname.dirname.dirname.basename.to_s,
"artifacts" => artifacts,
}
api_source["url_specs"] ||= fallback_cask.to_installed_json_hash["url_specs"] if fallback_cask
# Prefer the installed JSON's source path because it belongs to the installed version.
if (source_json = load_installed_json(path))
source_url_specs = source_json["url_specs"]
api_source["url_specs"] = source_url_specs if source_url_specs.is_a?(Hash)
end
# Load through the installed-JSON path so reconstructed artifacts have normal installed paths and behaviour.
recovered_cask = FromAPILoader.new(
token,
from_json: api_source,
path:,
from_installed_caskfile: true,
).load(config:)
recovered_cask unless recovered_cask.uninstall_flight_blocks?
rescue CaskInvalidError, CaskUnavailableError, MethodDeprecatedError, JSON::ParserError
# Recovery is best effort; callers treat nil as an unavailable installed cask and use their existing fallback.
nil
end
sig { params(token: T.any(String, Symbol)).returns(Pathname) }
def self.default_path(token)
find_cask_in_tap(token.to_s.downcase, CoreCaskTap.instance)
end
sig { params(token: String, tap: Tap).returns(Pathname) }
def self.find_cask_in_tap(token, tap)
filename = "#{token}.rb"
tap.cask_files_by_name.fetch(token, tap.cask_dir/filename)
end
end
end