mirror of
https://github.com/Homebrew/brew.git
synced 2026-08-12 22:29:27 +04:00
- 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.
944 lines
32 KiB
Ruby
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
|