diff --git a/Library/Homebrew/api.rb b/Library/Homebrew/api.rb index e4028b26781eb..a6dadf6fa6c3f 100644 --- a/Library/Homebrew/api.rb +++ b/Library/Homebrew/api.rb @@ -21,6 +21,7 @@ module API API_DOMAIN = "https://formulae.brew.sh/api" HOMEBREW_CACHE_API = (HOMEBREW_CACHE/"api").freeze + MAX_RETRIES = 3 sig { params(endpoint: String, json: T::Boolean).returns(T.any(String, Hash)) } def fetch(endpoint, json: true) @@ -38,5 +39,24 @@ def fetch(endpoint, json: true) rescue JSON::ParserError raise ArgumentError, "Invalid JSON file: #{Tty.underline}#{api_url}#{Tty.reset}" end + + def fetch_json_api_file(endpoint, target:) + retry_count = 0 + + url = "#{API_DOMAIN}/#{endpoint}" + begin + curl_args = %W[--compressed --silent #{url}] + curl_args.prepend("--time-cond", target) if target.exist? && !target.empty? + Utils::Curl.curl_download(*curl_args, to: target, max_time: 5) + + JSON.parse(target.read) + rescue JSON::ParserError + target.unlink + retry_count += 1 + odie "Cannot download non-corrupt #{url}!" if retry_count > MAX_RETRIES + + retry + end + end end end diff --git a/Library/Homebrew/api/cask.rb b/Library/Homebrew/api/cask.rb index b7ec658bf2382..f0ed248cdf809 100644 --- a/Library/Homebrew/api/cask.rb +++ b/Library/Homebrew/api/cask.rb @@ -14,6 +14,18 @@ class << self def fetch(name) Homebrew::API.fetch "cask/#{name}.json" end + + sig { returns(Hash) } + def all_casks + @all_casks ||= begin + json_casks = Homebrew::API.fetch_json_api_file "cask.json", + target: HOMEBREW_CACHE_API/"cask.json" + + json_casks.to_h do |json_cask| + [json_cask["token"], json_cask.except("token")] + end + end + end end end end diff --git a/Library/Homebrew/api/formula.rb b/Library/Homebrew/api/formula.rb index 0e62566220fe4..48ebf47fdc173 100644 --- a/Library/Homebrew/api/formula.rb +++ b/Library/Homebrew/api/formula.rb @@ -10,45 +10,16 @@ module Formula class << self extend T::Sig - MAX_RETRIES = 3 - - sig { returns(String) } - def formula_api_path - "formula" - end - alias generic_formula_api_path formula_api_path - - sig { returns(String) } - def cached_formula_json_file - HOMEBREW_CACHE_API/"#{formula_api_path}.json" - end - sig { params(name: String).returns(Hash) } def fetch(name) - Homebrew::API.fetch "#{formula_api_path}/#{name}.json" + Homebrew::API.fetch "formula/#{name}.json" end sig { returns(Hash) } def all_formulae @all_formulae ||= begin - retry_count = 0 - - url = "https://formulae.brew.sh/api/formula.json" - json_formulae = begin - curl_args = %W[--compressed --silent #{url}] - if cached_formula_json_file.exist? && !cached_formula_json_file.empty? - curl_args.prepend("--time-cond", cached_formula_json_file) - end - curl_download(*curl_args, to: cached_formula_json_file, max_time: 5) - - JSON.parse(cached_formula_json_file.read) - rescue JSON::ParserError - cached_formula_json_file.unlink - retry_count += 1 - odie "Cannot download non-corrupt #{url}!" if retry_count > MAX_RETRIES - - retry - end + json_formulae = Homebrew::API.fetch_json_api_file "formula.json", + target: HOMEBREW_CACHE_API/"formula.json" @all_aliases = {} json_formulae.to_h do |json_formula| diff --git a/Library/Homebrew/cask/cask_loader.rb b/Library/Homebrew/cask/cask_loader.rb index 28c53f7527bde..2e67869755999 100644 --- a/Library/Homebrew/cask/cask_loader.rb +++ b/Library/Homebrew/cask/cask_loader.rb @@ -186,6 +186,100 @@ def load(config:) end end + # Loads a cask from the JSON API. + class FromAPILoader + attr_reader :token, :path + + FLIGHT_STANZAS = [:preflight, :postflight, :uninstall_preflight, :uninstall_postflight].freeze + + def self.can_load?(ref) + Homebrew::API::Cask.all_casks.key? ref + end + + def initialize(token) + @token = token + @path = CaskLoader.default_path(token) + end + + def load(config:) + json_cask = Homebrew::API::Cask.all_casks[token] + + if (bottle_tag = ::Utils::Bottles.tag.to_s.presence) && + (variations = json_cask["variations"].presence) && + (variation = variations[bottle_tag].presence) + json_cask = json_cask.merge(variation) + end + + json_cask.deep_symbolize_keys! + + # Use the cask-source API if there are any `*flight` blocks + if json_cask[:artifacts].any? { |artifact| FLIGHT_STANZAS.include?(artifact.keys.first) } + cask_source = Homebrew::API::CaskSource.fetch(token) + return FromContentLoader.new(cask_source).load(config: config) + end + + Cask.new(token, source: cask_source, config: config) do + version json_cask[:version] + + if json_cask[:sha256] == "no_check" + sha256 :no_check + else + sha256 json_cask[:sha256] + end + + url json_cask[:url] + appcast json_cask[:appcast] if json_cask[:appcast].present? + json_cask[:name].each do |cask_name| + name cask_name + end + desc json_cask[:desc] + homepage json_cask[:homepage] + + auto_updates json_cask[:auto_updates] if json_cask[:auto_updates].present? + 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| + next [dep_key, dep_value] unless dep_key == :macos + + dep_type = dep_value.keys.first + if dep_type == :== + version_symbols = dep_value[dep_type].map do |version| + MacOSVersions::SYMBOLS.key(version) || version + end + next [dep_key, version_symbols] + end + + version_symbol = dep_value[dep_type].first + version_symbol = MacOSVersions::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] unless container_key == :type + + [container_key, container_value.to_sym] + end + container(**container_hash) + end + + json_cask[:artifacts].each do |artifact| + key = artifact.keys.first + if FLIGHT_STANZAS.include?(key) + instance_eval(artifact[key]) + else + send(key, *artifact[key]) + end + end + + caveats json_cask[:caveats] if json_cask[:caveats].present? + end + end + end + # Pseudo-loader which raises an error when trying to load the corresponding cask. class NullLoader < FromPathLoader extend T::Sig @@ -225,15 +319,15 @@ def self.for(ref, need_path: false) next unless loader_class.can_load?(ref) if loader_class == FromTapLoader && Homebrew::EnvConfig.install_from_api? && - ref.start_with?("homebrew/cask/") && Homebrew::API::CaskSource.available?(ref) - return FromContentLoader.new(Homebrew::API::CaskSource.fetch(ref)) + ref.start_with?("homebrew/cask/") && FromAPILoader.can_load?(ref) + return FromAPILoader.new(ref) end return loader_class.new(ref) end if Homebrew::EnvConfig.install_from_api? && !need_path && Homebrew::API::CaskSource.available?(ref) - return FromContentLoader.new(Homebrew::API::CaskSource.fetch(ref)) + return FromAPILoader.new(ref) end return FromTapPathLoader.new(default_path(ref)) if FromTapPathLoader.can_load?(default_path(ref)) diff --git a/Library/Homebrew/cmd/update.sh b/Library/Homebrew/cmd/update.sh index a171c450b46d7..c77a42845712f 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -754,28 +754,32 @@ EOS if [[ -n "${HOMEBREW_INSTALL_FROM_API}" ]] then mkdir -p "${HOMEBREW_CACHE}/api" - if [[ -f "${HOMEBREW_CACHE}/api/formula.json" ]] - then - INITIAL_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/formula.json)" - fi - curl \ - "${CURL_DISABLE_CURLRC_ARGS[@]}" \ - --fail --compressed --silent --max-time 5 \ - --location --remote-time --output "${HOMEBREW_CACHE}/api/formula.json" \ - --time-cond "${HOMEBREW_CACHE}/api/formula.json" \ - --user-agent "${HOMEBREW_USER_AGENT_CURL}" \ - "https://formulae.brew.sh/api/formula.json" - curl_exit_code=$? - if [[ ${curl_exit_code} -eq 0 ]] - then - CURRENT_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/formula.json)" - if [[ "${INITIAL_JSON_BYTESIZE}" != "${CURRENT_JSON_BYTESIZE}" ]] + + for formula_or_cask in formula cask + do + if [[ -f "${HOMEBREW_CACHE}/api/${formula_or_cask}.json" ]] then - HOMEBREW_UPDATED="1" + INITIAL_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/"${formula_or_cask}".json)" fi - else - echo "Failed to download formula.json!" >>"${update_failed_file}" - fi + curl \ + "${CURL_DISABLE_CURLRC_ARGS[@]}" \ + --fail --compressed --silent --max-time 5 \ + --location --remote-time --output "${HOMEBREW_CACHE}/api/${formula_or_cask}.json" \ + --time-cond "${HOMEBREW_CACHE}/api/${formula_or_cask}.json" \ + --user-agent "${HOMEBREW_USER_AGENT_CURL}" \ + "https://formulae.brew.sh/api/${formula_or_cask}.json" + curl_exit_code=$? + if [[ ${curl_exit_code} -eq 0 ]] + then + CURRENT_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/"${formula_or_cask}".json)" + if [[ "${INITIAL_JSON_BYTESIZE}" != "${CURRENT_JSON_BYTESIZE}" ]] + then + HOMEBREW_UPDATED="1" + fi + else + echo "Failed to download ${formula_or_cask}.json!" >>"${update_failed_file}" + fi + done fi safe_cd "${HOMEBREW_REPOSITORY}" diff --git a/Library/Homebrew/dev-cmd/bottle.rb b/Library/Homebrew/dev-cmd/bottle.rb index 6104050fc7f68..73301317dd673 100644 --- a/Library/Homebrew/dev-cmd/bottle.rb +++ b/Library/Homebrew/dev-cmd/bottle.rb @@ -557,11 +557,10 @@ def bottle_formula(f, args:) "date" => Pathname(filename.to_s).mtime.strftime("%F"), "tags" => { bottle_tag.to_s => { - "filename" => filename.url_encode, - "local_filename" => filename.to_s, - "sha256" => sha256, - "formulae_brew_sh_path" => Homebrew::API::Formula.formula_api_path, - "tab" => tab.to_bottle_hash, + "filename" => filename.url_encode, + "local_filename" => filename.to_s, + "sha256" => sha256, + "tab" => tab.to_bottle_hash, }, }, }, diff --git a/Library/Homebrew/github_packages.rb b/Library/Homebrew/github_packages.rb index 0527dcb401763..e50a1cbb62fdf 100644 --- a/Library/Homebrew/github_packages.rb +++ b/Library/Homebrew/github_packages.rb @@ -354,8 +354,7 @@ def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old: config_json_sha256, config_json_size = write_image_config(platform_hash, tar_sha256.hexdigest, blobs) - formulae_dir = tag_hash["formulae_brew_sh_path"] - documentation = "https://formulae.brew.sh/#{formulae_dir}/#{formula_name}" if formula_core_tap + documentation = "https://formulae.brew.sh/formula/#{formula_name}" if formula_core_tap descriptor_annotations_hash = { "org.opencontainers.image.ref.name" => tag, diff --git a/Library/Homebrew/test/api/cask_spec.rb b/Library/Homebrew/test/api/cask_spec.rb new file mode 100644 index 0000000000000..35c561b8f9c18 --- /dev/null +++ b/Library/Homebrew/test/api/cask_spec.rb @@ -0,0 +1,44 @@ +# typed: false +# frozen_string_literal: true + +require "api" + +describe Homebrew::API::Cask do + let(:cache_dir) { mktmpdir } + + before do + stub_const("Homebrew::API::HOMEBREW_CACHE_API", cache_dir) + end + + def mock_curl_download(stdout:) + allow(Utils::Curl).to receive(:curl_download) do |*_args, **kwargs| + kwargs[:to].write stdout + end + end + + describe "::all_casks" do + let(:casks_json) { + <<~EOS + [{ + "token": "foo", + "url": "https://brew.sh/foo" + }, { + "token": "bar", + "url": "https://brew.sh/bar" + }] + EOS + } + let(:casks_hash) { + { + "foo" => { "url" => "https://brew.sh/foo" }, + "bar" => { "url" => "https://brew.sh/bar" }, + } + } + + it "returns the expected cask JSON list" do + mock_curl_download stdout: casks_json + casks_output = described_class.all_casks + expect(casks_output).to eq casks_hash + end + end +end diff --git a/Library/Homebrew/test/api/formula_spec.rb b/Library/Homebrew/test/api/formula_spec.rb new file mode 100644 index 0000000000000..38dbb57d5fe62 --- /dev/null +++ b/Library/Homebrew/test/api/formula_spec.rb @@ -0,0 +1,64 @@ +# typed: false +# frozen_string_literal: true + +require "api" + +describe Homebrew::API::Formula do + let(:cache_dir) { mktmpdir } + + before do + stub_const("Homebrew::API::HOMEBREW_CACHE_API", cache_dir) + end + + def mock_curl_download(stdout:) + allow(Utils::Curl).to receive(:curl_download) do |*_args, **kwargs| + kwargs[:to].write stdout + end + end + + describe "::all_formulae" do + let(:formulae_json) { + <<~EOS + [{ + "name": "foo", + "url": "https://brew.sh/foo", + "aliases": ["foo-alias1", "foo-alias2"] + }, { + "name": "bar", + "url": "https://brew.sh/bar", + "aliases": ["bar-alias"] + }, { + "name": "baz", + "url": "https://brew.sh/baz", + "aliases": [] + }] + EOS + } + let(:formulae_hash) { + { + "foo" => { "url" => "https://brew.sh/foo", "aliases" => ["foo-alias1", "foo-alias2"] }, + "bar" => { "url" => "https://brew.sh/bar", "aliases" => ["bar-alias"] }, + "baz" => { "url" => "https://brew.sh/baz", "aliases" => [] }, + } + } + let(:formulae_aliases) { + { + "foo-alias1" => "foo", + "foo-alias2" => "foo", + "bar-alias" => "bar", + } + } + + it "returns the expected formula JSON list" do + mock_curl_download stdout: formulae_json + formulae_output = described_class.all_formulae + expect(formulae_output).to eq formulae_hash + end + + it "returns the expected formula alias list" do + mock_curl_download stdout: formulae_json + aliases_output = described_class.all_aliases + expect(aliases_output).to eq formulae_aliases + end + end +end diff --git a/Library/Homebrew/test/api_spec.rb b/Library/Homebrew/test/api_spec.rb index 6d70ef66269d8..ff695bfe7797f 100644 --- a/Library/Homebrew/test/api_spec.rb +++ b/Library/Homebrew/test/api_spec.rb @@ -7,12 +7,19 @@ let(:text) { "foo" } let(:json) { '{"foo":"bar"}' } let(:json_hash) { JSON.parse(json) } + let(:json_invalid) { '{"foo":"bar"' } def mock_curl_output(stdout: "", success: true) curl_output = OpenStruct.new(stdout: stdout, success?: success) allow(Utils::Curl).to receive(:curl_output).and_return curl_output end + def mock_curl_download(stdout:) + allow(Utils::Curl).to receive(:curl_download) do |*_args, **kwargs| + kwargs[:to].write stdout + end + end + describe "::fetch" do it "fetches a text file" do mock_curl_output stdout: text @@ -36,4 +43,31 @@ def mock_curl_output(stdout: "", success: true) expect { described_class.fetch("baz.txt") }.to raise_error(ArgumentError, /Invalid JSON file/) end end + + describe "::fetch_json_api_file" do + let!(:cache_dir) { mktmpdir } + + before do + (cache_dir/"bar.json").write "tmp" + end + + it "fetches a JSON file" do + mock_curl_download stdout: json + fetched_json = described_class.fetch_json_api_file("foo.json", target: cache_dir/"foo.json") + expect(fetched_json).to eq json_hash + end + + it "updates an existing JSON file" do + mock_curl_download stdout: json + fetched_json = described_class.fetch_json_api_file("bar.json", target: cache_dir/"bar.json") + expect(fetched_json).to eq json_hash + end + + it "raises an error if the JSON file is invalid" do + mock_curl_download stdout: json_invalid + expect { + described_class.fetch_json_api_file("baz.json", target: cache_dir/"baz.json") + }.to raise_error(SystemExit) + end + end end