# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/cache" require "cask/cask" require "uri" require "utils/curl" require "extend/hash/keys" module Cask # Loads a cask from various sources. module CaskLoader extend Context ALLOWED_URL_SCHEMES = %w[file].freeze private_constant :ALLOWED_URL_SCHEMES module ILoader extend T::Helpers 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 { returns(T.nilable(Tap)) } attr_reader :tap 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 ||= 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 return unless content.match?(@regex) new(content) end sig { params(content: String, tap: Tap).void } def initialize(content, tap: T.unsafe(nil)) super() @content = content.dup.force_encoding("UTF-8") @tap = tap end def load(config:) @config = config instance_eval(content, __FILE__, __LINE__) 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 if %w[.rb .json].exclude?(path.extname) return unless path.expand_path.exist? return if Homebrew::EnvConfig.forbid_packages_from_paths? && !path.realpath.to_s.start_with?("#{Caskroom.path}/", "#{HOMEBREW_LIBRARY}/Taps/") new(path) end attr_reader :token, :path sig { params(path: T.any(Pathname, String), token: String).void } def initialize(path, token: T.unsafe(nil)) super() path = Pathname(path).expand_path @token = path.basename(path.extname).to_s @path = path @tap = Tap.from_path(path) || Homebrew::API.tap_from_source_download(path) 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? @content = path.read(encoding: "UTF-8") @config = config if path.extname == ".json" return FromAPILoader.new(token, from_json: JSON.parse(@content), path:).load(config:) end begin instance_eval(content, path).tap do |cask| raise CaskUnreadableError.new(token, "'#{path}' does not contain a cask.") unless cask.is_a?(Cask) end rescue NameError, ArgumentError, ScriptError => e error = CaskUnreadableError.new(token, e.message) error.set_backtrace e.backtrace raise error end end private 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 ||= begin uri_regex = ::URI::DEFAULT_PARSER.make_regexp Regexp.new("\\A#{uri_regex.source}\\Z", uri_regex.options) end uri = ref.to_s return unless uri.match?(@uri_regex) uri = URI(uri) return unless uri.path new(uri) end attr_reader :url, :name sig { params(url: T.any(URI::Generic, String)).void } def initialize(url) @url = URI(url) @name = File.basename(T.must(@url.path)) super Cache.path/name end 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 extract` or `brew create` and `brew tap-new` to create a formula file in a tap " \ "on GitHub instead." end begin ohai "Downloading #{url}" ::Utils::Curl.curl_download url, 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 { 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:)) 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, token = Tap.with_cask_token(tapped_token) cask = CaskLoader.find_cask_in_tap(token, tap) super cask end sig { override.params(config: T.nilable(Config)).returns(Cask) } def load(config:) raise TapCaskUnavailableError.new(tap, token) unless T.must(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(T.attached_class)) } 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 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(Hash)) } attr_reader :from_json sig { 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.no_install_from_api? return unless ref.is_a?(String) return unless (token = ref[HOMEBREW_DEFAULT_TAP_CASK_REGEX, :token]) if !Homebrew::API::Cask.all_casks.key?(token) && !Homebrew::API::Cask.all_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: Hash, path: T.nilable(Pathname)).void } def initialize(token, from_json: T.unsafe(nil), path: nil) @token = token.sub(%r{^homebrew/(?:homebrew-)?cask/}i, "") @sourcefile_path = path || Homebrew::API::Cask.cached_json_file_path @path = path || CaskLoader.default_path(@token) @from_json = from_json end def load(config:) json_cask = from_json || Homebrew::API::Cask.all_casks.fetch(token) cask_options = { loaded_from_api: true, sourcefile_path: @sourcefile_path, source: JSON.pretty_generate(json_cask), config:, loader: self, } json_cask = Homebrew::API.merge_variations(json_cask).deep_symbolize_keys.freeze cask_options[:tap] = Tap.fetch(json_cask[:tap]) if json_cask[:tap].to_s.include?("/") user_agent = json_cask.dig(:url_specs, :user_agent) json_cask[:url_specs][:user_agent] = user_agent[1..].to_sym if user_agent && user_agent[0] == ":" if (using = json_cask.dig(:url_specs, :using)) json_cask[:url_specs][:using] = using.to_sym end api_cask = Cask.new(token, **cask_options) do version json_cask[:version] if json_cask[:sha256] == "no_check" sha256 :no_check else sha256 json_cask[:sha256] end url json_cask[:url], **json_cask.fetch(:url_specs, {}) if json_cask[:url].present? json_cask[:name]&.each do |cask_name| name cask_name end desc json_cask[:desc] homepage json_cask[:homepage] if (deprecation_date = json_cask[:deprecation_date].presence) reason = DeprecateDisable.to_reason_string_or_symbol json_cask[:deprecation_reason], type: :cask deprecate! date: deprecation_date, because: reason end if (disable_date = json_cask[:disable_date].presence) reason = DeprecateDisable.to_reason_string_or_symbol json_cask[:disable_reason], type: :cask disable! date: disable_date, because: reason end auto_updates json_cask[:auto_updates] unless json_cask[:auto_updates].nil? conflicts_with(**json_cask[:conflicts_with]) if json_cask[:conflicts_with].present? if json_cask[:depends_on].present? dep_hash = json_cask[:depends_on].to_h do |dep_key, dep_value| # Arch dependencies are encoded like `{ type: :intel, bits: 64 }` # but `depends_on arch:` only accepts `:intel` or `:arm64` if dep_key == :arch next [:arch, :intel] if dep_value.first[:type] == "intel" next [:arch, :arm64] end next [dep_key, dep_value] if dep_key != :macos dep_type = dep_value.keys.first if dep_type == :== version_symbols = dep_value[dep_type].map do |version| MacOSVersion::SYMBOLS.key(version) || version end next [dep_key, version_symbols] end version_symbol = dep_value[dep_type].first version_symbol = MacOSVersion::SYMBOLS.key(version_symbol) || version_symbol [dep_key, "#{dep_type} :#{version_symbol}"] end.compact depends_on(**dep_hash) end if json_cask[:container].present? container_hash = json_cask[:container].to_h do |container_key, container_value| next [container_key, container_value] if container_key != :type [container_key, container_value.to_sym] end container(**container_hash) end json_cask[:artifacts].each do |artifact| # convert generic string replacements into actual ones artifact = cask.loader.from_h_gsubs(artifact, appdir) key = artifact.keys.first if artifact[key].nil? # for artifacts with blocks that can't be loaded from the API send(key) {} # empty on purpose else args = artifact[key] kwargs = if args.last.is_a?(Hash) args.pop else {} end send(key, *args, **kwargs) end end if json_cask[:caveats].present? # convert generic string replacements into actual ones caveats cask.loader.from_h_string_gsubs(json_cask[:caveats], appdir) end end api_cask.populate_from_api!(json_cask) api_cask end def from_h_string_gsubs(string, appdir) string.to_s .gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home) .gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX) .gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR) .gsub(HOMEBREW_CASK_APPDIR_PLACEHOLDER, appdir) end def from_h_array_gsubs(array, appdir) array.to_a.map do |value| from_h_gsubs(value, appdir) end end def from_h_hash_gsubs(hash, appdir) hash.to_h.transform_values do |value| from_h_gsubs(value, appdir) end end def from_h_gsubs(value, appdir) return value if value.blank? case value when Hash from_h_hash_gsubs(value, appdir) when Array from_h_array_gsubs(value, appdir) when String from_h_string_gsubs(value, appdir) else value end end end # Loader which tries loading casks from tap paths, failing # if the same token exists in multiple taps. class FromNameLoader < FromTapLoader 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 # If it exists in the default tap, never treat it as ambiguous with another tap. if (core_cask_tap = CoreCaskTap.instance).installed? && (core_cask_loader = super("#{core_cask_tap}/#{token}", warn:))&.path&.exist? return core_cask_loader 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) .returns(T.nilable(T.attached_class)) } def self.try_new(ref, warn: false) return unless ref.is_a?(String) possible_installed_cask = Cask.new(ref) return unless (installed_caskfile = possible_installed_cask.installed_caskfile) new(installed_caskfile) 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 def load(config:) raise CaskUnavailableError.new(token, "No Cask with this name exists.") end end def self.path(ref) self.for(ref, need_path: true).path end def self.load(ref, config: nil, warn: true) self.for(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) || [Tap.fetch(new_tap_name), token] new_tap.ensure_installed! new_tapped_token = "#{new_tap}/#{new_token}" if tapped_token == new_tapped_token opoo "Tap migration for #{tapped_token} points to itself, stopping recursion." else 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 opoo "Cask #{old_token} was renamed to #{new_token}." if warn && old_token && new_token [token, tap, type] end 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 debug? return loader end end end def self.default_path(token) find_cask_in_tap(token.to_s.downcase, CoreCaskTap.instance) end 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