Files
brew/Library/Homebrew/cask/cask_loader.rb
T
Mike McQuaid 5ab33c9e63 Parse only needed internal API payload entries
- Every command loading internal API data paid a full `JSON.parse` of
  the 13MB packages payload (~8500 formulae and ~7700 casks, ~80ms and
  a large retained object graph) even when installing one formula.
- Add `Homebrew::API::PackagesIndex`, a byte-offset index sidecar over
  the signature-verified payload. The index is derived, unverified
  cache data guarded in layers: the payload bytes it points into are
  RSA-PSS-verified every run, loading requires the recorded top-level
  spans to tile that payload exactly (proving the formulae and casks
  section spans are the real top-level values) and every lookup
  revalidates that its offsets sit at the expected `"<name>":` key
  inside the requested section's span and that the slice parses. A
  forged or stale index therefore cannot inject unverified content or
  remap a name to another entry, even a same-named key in the other
  section; it fails validation and callers fall back to a full parse.
- Build the index whenever a download, revalidation or index miss has
  already paid for a full parse, by locating each entry's bytes via
  their exact `JSON.generate` round trip (the payload is generated by
  the same serialiser, verified against the whole document up front).
- `Internal` now serves per-name entries (`formula_hash`, `cask_hash`,
  name lists and membership) and the small top-level keys through the
  index, materialising `formula_hashes`/`cask_hashes` only for
  full-iteration callers such as `search` and `formula_auditor`.
- `write_names_file!`, `write_aliases_file!` and
  `write_executables_file!` take blocks so their freshness short
  circuits no longer materialise every entry on warm loads.
- `brew update` prewarms the index: `brew update-report`'s existing
  `write_names_and_aliases` call rebuilds the payload sidecar and
  index whenever the envelope changed, during the full parse it needs
  for the names files anyway, so the first command after an update
  loads through the index. The previous-OS-version removals in
  `cmd/update.sh` and `brew cleanup --scrub` keep the current OS's
  `.payload.index` alongside its `.payload` instead of deleting it on
  every update.
- Internal API data load drops from ~100ms to ~30ms in `brew install`
  (signature verification retained) and a warm no-op `brew install`
  from ~530ms to ~440ms.
2026-07-31 17:43:41 +01:00

944 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_hash(token)
raise KeyError, "key not found: #{token.inspect}" if api_source.nil?
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