From 48e39bb51d6a22f90842811403f41688f9a95faa Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 8 Apr 2024 16:18:15 -0400 Subject: [PATCH 01/15] attestation: add initial attestation helpers Adds the basic attestation verification APIs, as well as a pre-pour check against `HOMEBREW_VERIFY_ATTESTATIONS` that verifies the attestation (or backfill as necessary) for bottles from homebrew-core. Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 82 +++++++++++++++++++++++++++ Library/Homebrew/brew.sh | 16 ++++++ Library/Homebrew/cmd/update.sh | 13 ++++- Library/Homebrew/env_config.rb | 5 ++ Library/Homebrew/exceptions.rb | 3 + Library/Homebrew/formula_installer.rb | 6 ++ Library/Homebrew/global.rb | 2 + 7 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 Library/Homebrew/attestation.rb diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb new file mode 100644 index 0000000000..4bfc18e96d --- /dev/null +++ b/Library/Homebrew/attestation.rb @@ -0,0 +1,82 @@ +# typed: true +# frozen_string_literal: true + +require "date" +require "json" +require "utils/popen" +require "exceptions" + +module Homebrew + module Attestation + HOMEBREW_CORE_REPO = "Homebrew/homebrew-core" + HOMEBREW_CORE_CI_URI = "https://github.com/Homebrew/homebrew-core/.github/workflows/publish-commit-bottles.yml@refs/heads/master" + + BACKFILL_REPO = "trailofbits/homebrew-brew-verify" + BACKFILL_REPO_CI_URI = "https://github.com/trailofbits/homebrew-brew-verify/.github/workflows/backfill_signatures.yml@refs/heads/main" + + # No backfill attestations after this date are considered valid. + # @api private + BACKFILL_CUTOFF = DateTime.new(2024, 3, 14) + + # Verifies the given bottle against a cryptographic attestation of build provenance. + # + # The provenance is verified as originating from `signing_repo`, which is a `String` + # that should be formatted as a GitHub `owner/repo`. + # + # Callers may additionally pass in `signing_workflow`, which will scope the attestation + # down to an exact GitHub Actions workflow, in + # `https://github/OWNER/REPO/.github/workflows/WORKFLOW.yml@REF` format. + # + # @return [Hash] the JSON-decoded response. + # @raises [InvalidAttestationError] on any verification failures. + # + # @api private + def self.check_attestation(bottle, signing_repo, signing_workflow = nil) + cmd = [HOMEBREW_GH, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] + + unless signing_workflow.nil? + cmd += ["--cert-identity", signing_workflow] + end + + begin + output = Utils.safe_popen_read(*cmd) + rescue ErrorDuringExecution => e + raise InvalidAttestationError, "attestation verification failed: #{e}" + end + + begin + data = JSON.parse(output) + rescue JSON::ParserError => e + raise InvalidAttestationError, "attestation verification returned malformed JSON" + end + + raise InvalidAttestationError, "attestation output is empty" if data.empty? + + data + end + + # Verifies the given bottle against a cryptographic attestation of build provenance + # from homebrew-core's CI, falling back on a "backfill" attestation for older bottles. + # + # This is a specialization of `check_attestation` for homebrew-core. + def self.check_core_attestation(bottle) + begin + attestation = check_attestation bottle, HOMEBREW_CORE_REPO + return attestation + rescue InvalidAttestationError + odebug "falling back on backfilled attestation" + backfill_attestation = check_attestation bottle, BACKFILL_REPO, BACKFILL_REPO_CI_URI + timestamp = backfill_attestation.dig(0, "verificationResult", "verifiedTimestamps", + 0, "timestamp") + + raise InvalidAttestationError, "backfill attestation is missing verified timestamp" if timestamp.nil? + + if DateTime.parse(timestamp) > BACKFILL_CUTOFF + raise InvalidAttestationError, "backfill attestation post-dates cutoff" + end + end + + backfill_attestation + end + end +end diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index a00510b400..7469af7b04 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -454,8 +454,23 @@ setup_git() { fi } +setup_gh() { + # This is set by the user environment. + # shellcheck disable=SC2154 + if [[ -n "${HOMEBREW_VERIFY_ATTESTATIONS}" && -x "${HOMEBREW_PREFIX}/opt/gh/bin/gh" ]] + then + HOMEBREW_GH="${HOMEBREW_PREFIX}/opt/gh/bin/gh" + elif [[ -n "${HOMEBREW_GH_PATH}" ]] + then + HOMEBREW_GH="${HOMEBREW_GH_PATH}" + else + HOMEBREW_GH="gh" + fi +} + setup_curl setup_git +setup_gh HOMEBREW_VERSION="$("${HOMEBREW_GIT}" -C "${HOMEBREW_REPOSITORY}" describe --tags --dirty --abbrev=7 2>/dev/null)" HOMEBREW_USER_AGENT_VERSION="${HOMEBREW_VERSION}" @@ -721,6 +736,7 @@ export HOMEBREW_API_DEFAULT_DOMAIN export HOMEBREW_BOTTLE_DEFAULT_DOMAIN export HOMEBREW_CURL_SPEED_LIMIT export HOMEBREW_CURL_SPEED_TIME +export HOMEBREW_GH if [[ -n "${HOMEBREW_MACOS}" && -x "/usr/bin/xcode-select" ]] then diff --git a/Library/Homebrew/cmd/update.sh b/Library/Homebrew/cmd/update.sh index b3806891df..25ae436498 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -15,7 +15,7 @@ # HOMEBREW_LIBRARY, HOMEBREW_PREFIX, HOMEBREW_REPOSITORY are set by bin/brew # HOMEBREW_BREW_DEFAULT_GIT_REMOTE, HOMEBREW_BREW_GIT_REMOTE, HOMEBREW_CACHE, HOMEBREW_CELLAR, HOMEBREW_CURL # HOMEBREW_DEV_CMD_RUN, HOMEBREW_FORCE_BREWED_CURL, HOMEBREW_FORCE_BREWED_GIT, HOMEBREW_SYSTEM_CURL_TOO_OLD -# HOMEBREW_USER_AGENT_CURL are set by brew.sh +# HOMEBREW_USER_AGENT_CURL, HOMEBREW_GH are set by brew.sh # shellcheck disable=SC2154 source "${HOMEBREW_LIBRARY}/Homebrew/utils/lock.sh" @@ -415,6 +415,17 @@ user account: EOS fi + # we need `gh` if the user enables attestation verification + if [[ -n "${HOMEBREW_VERIFY_ATTESTATIONS}" && ! -x "${HOMEBREW_GH}" ]] + then + # we cannot install `gh` if homebrew/core is unavailable. + # we don't enable attestations on `gh` itself, to prevent a bootstrap cycle. + if [[ -z "${HOMEBREW_CORE_AVAILABLE}" ]] || ! HOMEBREW_VERIFY_ATTESTATIONS='' brew install gh + then + odie "'gh' must be installed and in your PATH!" + fi + fi + # we may want to use Homebrew CA certificates if [[ -n "${HOMEBREW_FORCE_BREWED_CA_CERTIFICATES}" && ! -f "${HOMEBREW_PREFIX}/etc/ca-certificates/cert.pem" ]] then diff --git a/Library/Homebrew/env_config.rb b/Library/Homebrew/env_config.rb index 16d2db6222..4f6b8e62fd 100644 --- a/Library/Homebrew/env_config.rb +++ b/Library/Homebrew/env_config.rb @@ -422,6 +422,11 @@ module Homebrew "useful to avoid long-running Homebrew commands being killed due to no output.", boolean: true, }, + HOMEBREW_VERIFY_ATTESTATIONS: { + description: "If set, Homebrew will use the `gh` tool to verify cryptographic attestations " \ + "of build provenance for bottles from homebrew-core.", + boolean: true, + }, SUDO_ASKPASS: { description: "If set, pass the `-A` option when calling `sudo`(8).", }, diff --git a/Library/Homebrew/exceptions.rb b/Library/Homebrew/exceptions.rb index 1094c7e517..d625491e94 100644 --- a/Library/Homebrew/exceptions.rb +++ b/Library/Homebrew/exceptions.rb @@ -783,3 +783,6 @@ class CyclicDependencyError < RuntimeError EOS end end + +# Raised when attestation verification fails. +class InvalidAttestationError < RuntimeError; end diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index a0effec872..5bcecd2544 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -22,6 +22,7 @@ require "utils/spdx" require "deprecate_disable" require "unlink" require "service" +require "attestation" # Installer for a formula. # @@ -1256,6 +1257,11 @@ on_request: installed_on_request?, options:) sig { void } def pour + if Homebrew::EnvConfig.verify_attestations? && formula.tap&.core_tap? + ohai "Verifying attestation for #{formula.name}" + Homebrew::Attestation.check_core_attestation formula.bottle + end + HOMEBREW_CELLAR.cd do downloader.stage end diff --git a/Library/Homebrew/global.rb b/Library/Homebrew/global.rb index fdebb4a470..f0a95bd670 100644 --- a/Library/Homebrew/global.rb +++ b/Library/Homebrew/global.rb @@ -66,6 +66,8 @@ HOMEBREW_PULL_OR_COMMIT_URL_REGEX = %r[https://github\.com/([\w-]+)/([\w-]+)?/(?:pull/(\d+)|commit/[0-9a-fA-F]{4,40})] HOMEBREW_BOTTLES_EXTNAME_REGEX = /\.([a-z0-9_]+)\.bottle\.(?:(\d+)\.)?tar\.gz$/ +HOMEBREW_GH = Pathname(ENV.fetch("HOMEBREW_GH")).freeze + require "env_config" require "macos_version" require "os" From 578c2bc9da4c68bf50bd92d2dcf017f1473b3ac6 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 8 Apr 2024 16:21:31 -0400 Subject: [PATCH 02/15] rubocop fixes Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 8 +++----- Library/Homebrew/env_config.rb | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index 4bfc18e96d..beef17a876 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -16,7 +16,7 @@ module Homebrew # No backfill attestations after this date are considered valid. # @api private - BACKFILL_CUTOFF = DateTime.new(2024, 3, 14) + BACKFILL_CUTOFF = DateTime.new(2024, 3, 14).freeze # Verifies the given bottle against a cryptographic attestation of build provenance. # @@ -34,9 +34,7 @@ module Homebrew def self.check_attestation(bottle, signing_repo, signing_workflow = nil) cmd = [HOMEBREW_GH, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] - unless signing_workflow.nil? - cmd += ["--cert-identity", signing_workflow] - end + cmd += ["--cert-identity", signing_workflow] unless signing_workflow.nil? begin output = Utils.safe_popen_read(*cmd) @@ -46,7 +44,7 @@ module Homebrew begin data = JSON.parse(output) - rescue JSON::ParserError => e + rescue JSON::ParserError raise InvalidAttestationError, "attestation verification returned malformed JSON" end diff --git a/Library/Homebrew/env_config.rb b/Library/Homebrew/env_config.rb index 4f6b8e62fd..53f85a3778 100644 --- a/Library/Homebrew/env_config.rb +++ b/Library/Homebrew/env_config.rb @@ -422,7 +422,7 @@ module Homebrew "useful to avoid long-running Homebrew commands being killed due to no output.", boolean: true, }, - HOMEBREW_VERIFY_ATTESTATIONS: { + HOMEBREW_VERIFY_ATTESTATIONS: { description: "If set, Homebrew will use the `gh` tool to verify cryptographic attestations " \ "of build provenance for bottles from homebrew-core.", boolean: true, From 1881a1f4bc6a53cbc5ed658cdee7dcffc075b136 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 8 Apr 2024 16:22:57 -0400 Subject: [PATCH 03/15] attestation: more docs Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index beef17a876..3e74c8b683 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -8,10 +8,14 @@ require "exceptions" module Homebrew module Attestation + # @api private HOMEBREW_CORE_REPO = "Homebrew/homebrew-core" + # @api private HOMEBREW_CORE_CI_URI = "https://github.com/Homebrew/homebrew-core/.github/workflows/publish-commit-bottles.yml@refs/heads/master" + # @api private BACKFILL_REPO = "trailofbits/homebrew-brew-verify" + # @api private BACKFILL_REPO_CI_URI = "https://github.com/trailofbits/homebrew-brew-verify/.github/workflows/backfill_signatures.yml@refs/heads/main" # No backfill attestations after this date are considered valid. @@ -28,7 +32,7 @@ module Homebrew # `https://github/OWNER/REPO/.github/workflows/WORKFLOW.yml@REF` format. # # @return [Hash] the JSON-decoded response. - # @raises [InvalidAttestationError] on any verification failures. + # @raise [InvalidAttestationError] on any verification failures # # @api private def self.check_attestation(bottle, signing_repo, signing_workflow = nil) @@ -57,6 +61,11 @@ module Homebrew # from homebrew-core's CI, falling back on a "backfill" attestation for older bottles. # # This is a specialization of `check_attestation` for homebrew-core. + # + # @return [Hash] the JSON-decoded response + # @raise [InvalidAttestationError] on any verification failures + # + # @api private def self.check_core_attestation(bottle) begin attestation = check_attestation bottle, HOMEBREW_CORE_REPO From a99100bb6d8c42bd7f0f9645576b116be4a85ccc Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 8 Apr 2024 16:27:18 -0400 Subject: [PATCH 04/15] typechecking, clearer env usage Signed-off-by: William Woodruff --- Library/Homebrew/cmd/update.sh | 2 +- Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Library/Homebrew/cmd/update.sh b/Library/Homebrew/cmd/update.sh index 25ae436498..152c0f31bb 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -420,7 +420,7 @@ EOS then # we cannot install `gh` if homebrew/core is unavailable. # we don't enable attestations on `gh` itself, to prevent a bootstrap cycle. - if [[ -z "${HOMEBREW_CORE_AVAILABLE}" ]] || ! HOMEBREW_VERIFY_ATTESTATIONS='' brew install gh + if [[ -z "${HOMEBREW_CORE_AVAILABLE}" ]] || ! env -u HOMEBREW_VERIFY_ATTESTATIONS brew install gh then odie "'gh' must be installed and in your PATH!" fi diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi index a1a75c94cb..e7a56d8586 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi @@ -260,5 +260,8 @@ module Homebrew::EnvConfig sig { returns(T::Boolean) } def verbose_using_dots?; end + + sig { returns(T::Boolean) } + def verify_attestations?; end end end From ca6db498596b1515c89a859aa42aef9176f087a9 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 9 Apr 2024 10:18:08 -0400 Subject: [PATCH 05/15] Apply suggestions from code review Co-authored-by: Mike McQuaid --- Library/Homebrew/attestation.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index 3e74c8b683..2fee9fb641 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -38,7 +38,7 @@ module Homebrew def self.check_attestation(bottle, signing_repo, signing_workflow = nil) cmd = [HOMEBREW_GH, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] - cmd += ["--cert-identity", signing_workflow] unless signing_workflow.nil? + cmd += ["--cert-identity", signing_workflow] if signing_workflow.present? begin output = Utils.safe_popen_read(*cmd) @@ -52,7 +52,7 @@ module Homebrew raise InvalidAttestationError, "attestation verification returned malformed JSON" end - raise InvalidAttestationError, "attestation output is empty" if data.empty? + raise InvalidAttestationError, "attestation output is empty" if data.blank? data end @@ -71,7 +71,7 @@ module Homebrew attestation = check_attestation bottle, HOMEBREW_CORE_REPO return attestation rescue InvalidAttestationError - odebug "falling back on backfilled attestation" + odebug "falling back on backfilled attestation for #{bottle}" backfill_attestation = check_attestation bottle, BACKFILL_REPO, BACKFILL_REPO_CI_URI timestamp = backfill_attestation.dig(0, "verificationResult", "verifiedTimestamps", 0, "timestamp") From e52c2538321c199c430831ac4bef396cbf15ffcd Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 9 Apr 2024 10:45:44 -0400 Subject: [PATCH 06/15] attestation: simplify `gh` bootstrapping Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 9 ++++++++- Library/Homebrew/brew.sh | 16 ---------------- Library/Homebrew/cmd/update.sh | 13 +------------ Library/Homebrew/formula_installer.rb | 16 +++++++++++++++- Library/Homebrew/global.rb | 2 -- 5 files changed, 24 insertions(+), 32 deletions(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index 2fee9fb641..554b510bea 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -22,6 +22,12 @@ module Homebrew # @api private BACKFILL_CUTOFF = DateTime.new(2024, 3, 14).freeze + def self.gh_executable + @gh_executable ||= with_env("HOMEBREW_VERIFY_ATTESTATIONS" => nil) do + ensure_executable!("gh") + end + end + # Verifies the given bottle against a cryptographic attestation of build provenance. # # The provenance is verified as originating from `signing_repo`, which is a `String` @@ -36,7 +42,8 @@ module Homebrew # # @api private def self.check_attestation(bottle, signing_repo, signing_workflow = nil) - cmd = [HOMEBREW_GH, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] + cmd = [gh_executable, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", + "json"] cmd += ["--cert-identity", signing_workflow] if signing_workflow.present? diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index 7469af7b04..a00510b400 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -454,23 +454,8 @@ setup_git() { fi } -setup_gh() { - # This is set by the user environment. - # shellcheck disable=SC2154 - if [[ -n "${HOMEBREW_VERIFY_ATTESTATIONS}" && -x "${HOMEBREW_PREFIX}/opt/gh/bin/gh" ]] - then - HOMEBREW_GH="${HOMEBREW_PREFIX}/opt/gh/bin/gh" - elif [[ -n "${HOMEBREW_GH_PATH}" ]] - then - HOMEBREW_GH="${HOMEBREW_GH_PATH}" - else - HOMEBREW_GH="gh" - fi -} - setup_curl setup_git -setup_gh HOMEBREW_VERSION="$("${HOMEBREW_GIT}" -C "${HOMEBREW_REPOSITORY}" describe --tags --dirty --abbrev=7 2>/dev/null)" HOMEBREW_USER_AGENT_VERSION="${HOMEBREW_VERSION}" @@ -736,7 +721,6 @@ export HOMEBREW_API_DEFAULT_DOMAIN export HOMEBREW_BOTTLE_DEFAULT_DOMAIN export HOMEBREW_CURL_SPEED_LIMIT export HOMEBREW_CURL_SPEED_TIME -export HOMEBREW_GH if [[ -n "${HOMEBREW_MACOS}" && -x "/usr/bin/xcode-select" ]] then diff --git a/Library/Homebrew/cmd/update.sh b/Library/Homebrew/cmd/update.sh index 152c0f31bb..b3806891df 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -15,7 +15,7 @@ # HOMEBREW_LIBRARY, HOMEBREW_PREFIX, HOMEBREW_REPOSITORY are set by bin/brew # HOMEBREW_BREW_DEFAULT_GIT_REMOTE, HOMEBREW_BREW_GIT_REMOTE, HOMEBREW_CACHE, HOMEBREW_CELLAR, HOMEBREW_CURL # HOMEBREW_DEV_CMD_RUN, HOMEBREW_FORCE_BREWED_CURL, HOMEBREW_FORCE_BREWED_GIT, HOMEBREW_SYSTEM_CURL_TOO_OLD -# HOMEBREW_USER_AGENT_CURL, HOMEBREW_GH are set by brew.sh +# HOMEBREW_USER_AGENT_CURL are set by brew.sh # shellcheck disable=SC2154 source "${HOMEBREW_LIBRARY}/Homebrew/utils/lock.sh" @@ -415,17 +415,6 @@ user account: EOS fi - # we need `gh` if the user enables attestation verification - if [[ -n "${HOMEBREW_VERIFY_ATTESTATIONS}" && ! -x "${HOMEBREW_GH}" ]] - then - # we cannot install `gh` if homebrew/core is unavailable. - # we don't enable attestations on `gh` itself, to prevent a bootstrap cycle. - if [[ -z "${HOMEBREW_CORE_AVAILABLE}" ]] || ! env -u HOMEBREW_VERIFY_ATTESTATIONS brew install gh - then - odie "'gh' must be installed and in your PATH!" - fi - fi - # we may want to use Homebrew CA certificates if [[ -n "${HOMEBREW_FORCE_BREWED_CA_CERTIFICATES}" && ! -f "${HOMEBREW_PREFIX}/etc/ca-certificates/cert.pem" ]] then diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index 5bcecd2544..2d7985b160 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -1259,7 +1259,21 @@ on_request: installed_on_request?, options:) def pour if Homebrew::EnvConfig.verify_attestations? && formula.tap&.core_tap? ohai "Verifying attestation for #{formula.name}" - Homebrew::Attestation.check_core_attestation formula.bottle + begin + Homebrew::Attestation.check_core_attestation formula.bottle + rescue InvalidAttestationError => e + raise CannotInstallFormulaError, <<~EOS + The bottle for #{formula.name} has an invalid build provenance attestation. + + This may indicate that the bottle was not produced by the expected + tap, or was maliciously inserted into the expected tap's bottle + storage. + + Additional context: + + #{e} + EOS + end end HOMEBREW_CELLAR.cd do diff --git a/Library/Homebrew/global.rb b/Library/Homebrew/global.rb index f0a95bd670..fdebb4a470 100644 --- a/Library/Homebrew/global.rb +++ b/Library/Homebrew/global.rb @@ -66,8 +66,6 @@ HOMEBREW_PULL_OR_COMMIT_URL_REGEX = %r[https://github\.com/([\w-]+)/([\w-]+)?/(?:pull/(\d+)|commit/[0-9a-fA-F]{4,40})] HOMEBREW_BOTTLES_EXTNAME_REGEX = /\.([a-z0-9_]+)\.bottle\.(?:(\d+)\.)?tar\.gz$/ -HOMEBREW_GH = Pathname(ENV.fetch("HOMEBREW_GH")).freeze - require "env_config" require "macos_version" require "os" From a3a5f78de39d5e234d5bd5e314a313c5f322cf7b Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 9 Apr 2024 10:48:17 -0400 Subject: [PATCH 07/15] attestation: document gh_executable bootstrap cycle Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index 554b510bea..fcaa726ab5 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -22,7 +22,13 @@ module Homebrew # @api private BACKFILL_CUTOFF = DateTime.new(2024, 3, 14).freeze + # Returns a path to a suitable `gh` executable for attestation verification. + # + # @api private def self.gh_executable + # NOTE: We disable HOMEBREW_VERIFY_ATTESTATIONS when installing `gh` itself, + # to prevent a cycle during bootstrapping. This can eventually be resolved + # by vendoring a pure-Ruby Sigstore verifier client. @gh_executable ||= with_env("HOMEBREW_VERIFY_ATTESTATIONS" => nil) do ensure_executable!("gh") end From 5ec3dab1410b0a0c7f8e203ac4f23550cfb65875 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 9 Apr 2024 10:50:49 -0400 Subject: [PATCH 08/15] attestation: document BACKFILL_CUTOFF better Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index fcaa726ab5..0c47aff12e 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -19,6 +19,14 @@ module Homebrew BACKFILL_REPO_CI_URI = "https://github.com/trailofbits/homebrew-brew-verify/.github/workflows/backfill_signatures.yml@refs/heads/main" # No backfill attestations after this date are considered valid. + # + # This date is shortly after the backfill operation for homebrew-core + # completed, as can be seen here: . + # + # In effect, this means that, even if an attacker is able to compromise the backfill + # signing workflow, they will be unable to convince a verifier to accept their newer, + # malicious backfilled signatures. + # # @api private BACKFILL_CUTOFF = DateTime.new(2024, 3, 14).freeze From 2efef36313adb6ae2791ceeadfb5c7daa8bf4c50 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 9 Apr 2024 10:52:48 -0400 Subject: [PATCH 09/15] move InvalidAttestationError into Attestation mod Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 5 +++++ Library/Homebrew/exceptions.rb | 3 --- Library/Homebrew/formula_installer.rb | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index 0c47aff12e..ecf53d56f9 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -30,6 +30,11 @@ module Homebrew # @api private BACKFILL_CUTOFF = DateTime.new(2024, 3, 14).freeze + # Raised when attestation verification fails. + # + # @api private + class InvalidAttestationError < RuntimeError; end + # Returns a path to a suitable `gh` executable for attestation verification. # # @api private diff --git a/Library/Homebrew/exceptions.rb b/Library/Homebrew/exceptions.rb index d625491e94..1094c7e517 100644 --- a/Library/Homebrew/exceptions.rb +++ b/Library/Homebrew/exceptions.rb @@ -783,6 +783,3 @@ class CyclicDependencyError < RuntimeError EOS end end - -# Raised when attestation verification fails. -class InvalidAttestationError < RuntimeError; end diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index 2d7985b160..4c1503033b 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -1261,7 +1261,7 @@ on_request: installed_on_request?, options:) ohai "Verifying attestation for #{formula.name}" begin Homebrew::Attestation.check_core_attestation formula.bottle - rescue InvalidAttestationError => e + rescue Homebrew::Attestation::InvalidAttestationError => e raise CannotInstallFormulaError, <<~EOS The bottle for #{formula.name} has an invalid build provenance attestation. From 6e10001d49ea8be2671a4b54ac0313afc0747ab6 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 9 Apr 2024 11:03:41 -0400 Subject: [PATCH 10/15] attestation: strict typechecking Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index ecf53d56f9..c8523c9f0b 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "date" @@ -28,7 +28,7 @@ module Homebrew # malicious backfilled signatures. # # @api private - BACKFILL_CUTOFF = DateTime.new(2024, 3, 14).freeze + BACKFILL_CUTOFF = T.let(DateTime.new(2024, 3, 14).freeze, DateTime) # Raised when attestation verification fails. # @@ -38,13 +38,14 @@ module Homebrew # Returns a path to a suitable `gh` executable for attestation verification. # # @api private + sig { returns(Pathname) } def self.gh_executable # NOTE: We disable HOMEBREW_VERIFY_ATTESTATIONS when installing `gh` itself, # to prevent a cycle during bootstrapping. This can eventually be resolved # by vendoring a pure-Ruby Sigstore verifier client. - @gh_executable ||= with_env("HOMEBREW_VERIFY_ATTESTATIONS" => nil) do + @gh_executable ||= T.let(with_env("HOMEBREW_VERIFY_ATTESTATIONS" => nil) do ensure_executable!("gh") - end + end, T.nilable(Pathname)) end # Verifies the given bottle against a cryptographic attestation of build provenance. @@ -60,6 +61,10 @@ module Homebrew # @raise [InvalidAttestationError] on any verification failures # # @api private + sig { + params(bottle: Bottle, signing_repo: String, + signing_workflow: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped]) + } def self.check_attestation(bottle, signing_repo, signing_workflow = nil) cmd = [gh_executable, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] @@ -92,6 +97,7 @@ module Homebrew # @raise [InvalidAttestationError] on any verification failures # # @api private + sig { params(bottle: Bottle).returns(T::Hash[T.untyped, T.untyped]) } def self.check_core_attestation(bottle) begin attestation = check_attestation bottle, HOMEBREW_CORE_REPO From 990b7d77d62872ac7d9fbf4ee32aac89ff3eeb52 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 10 Apr 2024 17:57:01 -0400 Subject: [PATCH 11/15] attestation: fix a missing arg, add initial specs Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 2 +- Library/Homebrew/test/attestation_spec.rb | 43 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 Library/Homebrew/test/attestation_spec.rb diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index c8523c9f0b..1e28eae035 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -100,7 +100,7 @@ module Homebrew sig { params(bottle: Bottle).returns(T::Hash[T.untyped, T.untyped]) } def self.check_core_attestation(bottle) begin - attestation = check_attestation bottle, HOMEBREW_CORE_REPO + attestation = check_attestation bottle, HOMEBREW_CORE_REPO, HOMEBREW_CORE_CI_URI return attestation rescue InvalidAttestationError odebug "falling back on backfilled attestation for #{bottle}" diff --git a/Library/Homebrew/test/attestation_spec.rb b/Library/Homebrew/test/attestation_spec.rb new file mode 100644 index 0000000000..61c546874f --- /dev/null +++ b/Library/Homebrew/test/attestation_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "diagnostic" + +RSpec.describe Homebrew::Attestation do + subject(:attestation) { described_class } + + let(:fake_gh) { Pathname.new("/extremely/fake/gh") } + let(:fake_json_resp) { JSON.dump({ foo: "bar" }) } + let(:cached_download) { "/fake/cached/download" } + let(:fake_bottle) { instance_double(Bottle, cached_download:) } + + describe "::gh_executable" do + before do + allow(attestation).to receive(:ensure_executable!) + .and_return(fake_gh) + end + + it "returns a path to a gh executable" do + attestation.gh_executable == fake_gh + end + end + + describe "::check_core_attestation" do + before do + allow(attestation).to receive(:gh_executable) + .and_return(fake_gh) + + allow(Utils).to receive(:safe_popen_read) + .and_return(fake_json_resp) + end + + it "calls gh with args for homebrew-core" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + attestation::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", + attestation::HOMEBREW_CORE_CI_URI) + .and_return(fake_json_resp) + + attestation.check_core_attestation fake_bottle + end + end +end From 480e48b75dc68f0d385a58d4e2be39a73aeb45b6 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 10 Apr 2024 18:02:56 -0400 Subject: [PATCH 12/15] attestation_spec: simplify gh_executable test Signed-off-by: William Woodruff --- Library/Homebrew/test/attestation_spec.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Library/Homebrew/test/attestation_spec.rb b/Library/Homebrew/test/attestation_spec.rb index 61c546874f..04e897e077 100644 --- a/Library/Homebrew/test/attestation_spec.rb +++ b/Library/Homebrew/test/attestation_spec.rb @@ -11,13 +11,12 @@ RSpec.describe Homebrew::Attestation do let(:fake_bottle) { instance_double(Bottle, cached_download:) } describe "::gh_executable" do - before do - allow(attestation).to receive(:ensure_executable!) + it "calls ensure_executable" do + expect(attestation).to receive(:ensure_executable!) + .with("gh") .and_return(fake_gh) - end - it "returns a path to a gh executable" do - attestation.gh_executable == fake_gh + attestation.gh_executable end end From e2b5d9319867537eb58f536069769f993f7aa068 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 11 Apr 2024 13:39:13 -0400 Subject: [PATCH 13/15] more attestation coverage Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 16 ++- Library/Homebrew/software_spec.rb | 5 + Library/Homebrew/test/attestation_spec.rb | 124 +++++++++++++++++++--- 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index 1e28eae035..1b4dee3dcb 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -78,14 +78,22 @@ module Homebrew end begin - data = JSON.parse(output) + attestations = JSON.parse(output) rescue JSON::ParserError raise InvalidAttestationError, "attestation verification returned malformed JSON" end - raise InvalidAttestationError, "attestation output is empty" if data.blank? + # `gh attestation verify` returns a JSON array of one or more results, + # for all attestations that match the input's digest. We want to additionally + # filter these down to just the attestation whose subject matches the bottle's name. + bottle_name = bottle.filename.to_s + attestation = attestations.find do |a| + a.dig("verificationResult", "statement", "subject", 0, "name") == bottle_name + end - data + raise InvalidAttestationError, "no attestation matches subject" if attestation.blank? + + attestation end # Verifies the given bottle against a cryptographic attestation of build provenance @@ -105,7 +113,7 @@ module Homebrew rescue InvalidAttestationError odebug "falling back on backfilled attestation for #{bottle}" backfill_attestation = check_attestation bottle, BACKFILL_REPO, BACKFILL_REPO_CI_URI - timestamp = backfill_attestation.dig(0, "verificationResult", "verifiedTimestamps", + timestamp = backfill_attestation.dig("verificationResult", "verifiedTimestamps", 0, "timestamp") raise InvalidAttestationError, "backfill attestation is missing verified timestamp" if timestamp.nil? diff --git a/Library/Homebrew/software_spec.rb b/Library/Homebrew/software_spec.rb index 42d2477bb4..fffd5116a4 100644 --- a/Library/Homebrew/software_spec.rb +++ b/Library/Homebrew/software_spec.rb @@ -423,6 +423,11 @@ class Bottle github_packages_manifest_resource_tab(github_packages_manifest_resource) end + sig { returns(Filename) } + def filename + Filename.create(resource.owner, @tag, @spec.rebuild) + end + private def github_packages_manifest_resource_tab(github_packages_manifest_resource) diff --git a/Library/Homebrew/test/attestation_spec.rb b/Library/Homebrew/test/attestation_spec.rb index 04e897e077..5ac6f29e2d 100644 --- a/Library/Homebrew/test/attestation_spec.rb +++ b/Library/Homebrew/test/attestation_spec.rb @@ -3,40 +3,138 @@ require "diagnostic" RSpec.describe Homebrew::Attestation do - subject(:attestation) { described_class } - let(:fake_gh) { Pathname.new("/extremely/fake/gh") } - let(:fake_json_resp) { JSON.dump({ foo: "bar" }) } let(:cached_download) { "/fake/cached/download" } - let(:fake_bottle) { instance_double(Bottle, cached_download:) } + let(:fake_bottle_filename) { instance_double(Bottle::Filename, to_s: "fakebottle--1.0.faketag.bottle.tar.gz") } + let(:fake_bottle) { instance_double(Bottle, cached_download:, filename: fake_bottle_filename) } + let(:fake_json_resp) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-13T00:00:00Z" }], + statement: { subject: [{ name: fake_bottle_filename.to_s }] }, + } }, + ]) + end + let(:fake_json_resp_too_new) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-15T00:00:00Z" }], + statement: { subject: [{ name: fake_bottle_filename.to_s }] }, + } }, + ]) + end + let(:fake_json_resp_wrong_sub) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-13T00:00:00Z" }], + statement: { subject: [{ name: "wrong-subject.tar.gz" }] }, + } }, + ]) + end describe "::gh_executable" do it "calls ensure_executable" do - expect(attestation).to receive(:ensure_executable!) + expect(described_class).to receive(:ensure_executable!) .with("gh") .and_return(fake_gh) - attestation.gh_executable + described_class.gh_executable + end + end + + describe "::check_attestation" do + before do + allow(described_class).to receive(:gh_executable) + .and_return(fake_gh) + end + + it "raises when gh subprocess fails" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json") + .and_raise(ErrorDuringExecution.new(["foo"], status: 1)) + + expect do + described_class.check_attestation fake_bottle, + described_class::HOMEBREW_CORE_REPO + end.to raise_error(described_class::InvalidAttestationError) + end + + it "raises when gh returns invalid JSON" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json") + .and_return("\"invalid json") + + expect do + described_class.check_attestation fake_bottle, + described_class::HOMEBREW_CORE_REPO + end.to raise_error(described_class::InvalidAttestationError) + end + + it "raises when gh returns other subjects" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json") + .and_return(fake_json_resp_wrong_sub) + + expect do + described_class.check_attestation fake_bottle, + described_class::HOMEBREW_CORE_REPO + end.to raise_error(described_class::InvalidAttestationError) end end describe "::check_core_attestation" do before do - allow(attestation).to receive(:gh_executable) + allow(described_class).to receive(:gh_executable) .and_return(fake_gh) - - allow(Utils).to receive(:safe_popen_read) - .and_return(fake_json_resp) end it "calls gh with args for homebrew-core" do expect(Utils).to receive(:safe_popen_read) .with(fake_gh, "attestation", "verify", cached_download, "--repo", - attestation::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", - attestation::HOMEBREW_CORE_CI_URI) + described_class::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", + described_class::HOMEBREW_CORE_CI_URI) .and_return(fake_json_resp) - attestation.check_core_attestation fake_bottle + described_class.check_core_attestation fake_bottle + end + + it "calls gh with args for backfill when homebrew-core fails" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", + described_class::HOMEBREW_CORE_CI_URI) + .once + .and_raise(described_class::InvalidAttestationError) + + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::BACKFILL_REPO, "--format", "json", "--cert-identity", + described_class::BACKFILL_REPO_CI_URI) + .and_return(fake_json_resp) + + described_class.check_core_attestation fake_bottle + end + + it "raises when the backfilled attestation is too new" do + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::HOMEBREW_CORE_REPO, "--format", "json", "--cert-identity", + described_class::HOMEBREW_CORE_CI_URI) + .once + .and_raise(described_class::InvalidAttestationError) + + expect(Utils).to receive(:safe_popen_read) + .with(fake_gh, "attestation", "verify", cached_download, "--repo", + described_class::BACKFILL_REPO, "--format", "json", "--cert-identity", + described_class::BACKFILL_REPO_CI_URI) + .and_return(fake_json_resp_too_new) + + expect do + described_class.check_core_attestation fake_bottle + end.to raise_error(described_class::InvalidAttestationError) end end end From faa00c8c79f253337b8cbb00e8d548f80f1cacb2 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 11 Apr 2024 16:44:57 -0400 Subject: [PATCH 14/15] handle backfilled attestation subjects correctly Signed-off-by: William Woodruff --- Library/Homebrew/attestation.rb | 19 ++++++++++++++----- Library/Homebrew/test/attestation_spec.rb | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb index 1b4dee3dcb..63678dc1db 100644 --- a/Library/Homebrew/attestation.rb +++ b/Library/Homebrew/attestation.rb @@ -63,9 +63,9 @@ module Homebrew # @api private sig { params(bottle: Bottle, signing_repo: String, - signing_workflow: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped]) + signing_workflow: T.nilable(String), subject: T.nilable(String)).returns(T::Hash[T.untyped, T.untyped]) } - def self.check_attestation(bottle, signing_repo, signing_workflow = nil) + def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) cmd = [gh_executable, "attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] @@ -86,9 +86,9 @@ module Homebrew # `gh attestation verify` returns a JSON array of one or more results, # for all attestations that match the input's digest. We want to additionally # filter these down to just the attestation whose subject matches the bottle's name. - bottle_name = bottle.filename.to_s + subject = bottle.filename.to_s if subject.blank? attestation = attestations.find do |a| - a.dig("verificationResult", "statement", "subject", 0, "name") == bottle_name + a.dig("verificationResult", "statement", "subject", 0, "name") == subject end raise InvalidAttestationError, "no attestation matches subject" if attestation.blank? @@ -112,7 +112,16 @@ module Homebrew return attestation rescue InvalidAttestationError odebug "falling back on backfilled attestation for #{bottle}" - backfill_attestation = check_attestation bottle, BACKFILL_REPO, BACKFILL_REPO_CI_URI + + # Our backfilled attestation is a little unique: the subject is not just the bottle + # filename, but also has the bottle's hosted URL hash prepended to it. + # This was originally unintentional, but has a virtuous side effect of further + # limiting domain separation on the backfilled signatures (by committing them to + # their original bottle URLs). + url_sha256 = Digest::SHA256.hexdigest(bottle.url) + subject = "#{url_sha256}--#{bottle.filename}" + + backfill_attestation = check_attestation bottle, BACKFILL_REPO, BACKFILL_REPO_CI_URI, subject timestamp = backfill_attestation.dig("verificationResult", "verifiedTimestamps", 0, "timestamp") diff --git a/Library/Homebrew/test/attestation_spec.rb b/Library/Homebrew/test/attestation_spec.rb index 5ac6f29e2d..b04b0946a7 100644 --- a/Library/Homebrew/test/attestation_spec.rb +++ b/Library/Homebrew/test/attestation_spec.rb @@ -6,7 +6,10 @@ RSpec.describe Homebrew::Attestation do let(:fake_gh) { Pathname.new("/extremely/fake/gh") } let(:cached_download) { "/fake/cached/download" } let(:fake_bottle_filename) { instance_double(Bottle::Filename, to_s: "fakebottle--1.0.faketag.bottle.tar.gz") } - let(:fake_bottle) { instance_double(Bottle, cached_download:, filename: fake_bottle_filename) } + let(:fake_bottle_url) { "https://example.com/#{fake_bottle_filename}" } + let(:fake_bottle) do + instance_double(Bottle, cached_download:, filename: fake_bottle_filename, url: fake_bottle_url) + end let(:fake_json_resp) do JSON.dump([ { verificationResult: { @@ -15,6 +18,16 @@ RSpec.describe Homebrew::Attestation do } }, ]) end + let(:fake_json_resp_backfill) do + JSON.dump([ + { verificationResult: { + verifiedTimestamps: [{ timestamp: "2024-03-13T00:00:00Z" }], + statement: { + subject: [{ name: "#{Digest::SHA256.hexdigest(fake_bottle_url)}--#{fake_bottle_filename}" }], + }, + } }, + ]) + end let(:fake_json_resp_too_new) do JSON.dump([ { verificationResult: { @@ -113,7 +126,7 @@ RSpec.describe Homebrew::Attestation do .with(fake_gh, "attestation", "verify", cached_download, "--repo", described_class::BACKFILL_REPO, "--format", "json", "--cert-identity", described_class::BACKFILL_REPO_CI_URI) - .and_return(fake_json_resp) + .and_return(fake_json_resp_backfill) described_class.check_core_attestation fake_bottle end From 1607d04ad2e5b278b42cbcc61b8b1bf45a541475 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 12 Apr 2024 10:41:55 -0400 Subject: [PATCH 15/15] test: add `Bottle#filename` test Signed-off-by: William Woodruff --- .../test/software_spec/bottle_spec.rb | 63 +++---------------- .../bottle_specification_spec.rb | 62 ++++++++++++++++++ 2 files changed, 71 insertions(+), 54 deletions(-) create mode 100644 Library/Homebrew/test/software_spec/bottle_specification_spec.rb diff --git a/Library/Homebrew/test/software_spec/bottle_spec.rb b/Library/Homebrew/test/software_spec/bottle_spec.rb index 98aa029bcd..e6d8d9928a 100644 --- a/Library/Homebrew/test/software_spec/bottle_spec.rb +++ b/Library/Homebrew/test/software_spec/bottle_spec.rb @@ -1,62 +1,17 @@ # frozen_string_literal: true require "software_spec" +require "test/support/fixtures/testball_bottle" -RSpec.describe BottleSpecification do - subject(:bottle_spec) { described_class.new } +RSpec.describe Bottle do + describe "#filename" do + it "renders the bottle filename" do + bottle_spec = BottleSpecification.new + bottle_spec.sha256(arm64_big_sur: "deadbeef" * 8) + tag = Utils::Bottles::Tag.from_symbol :arm64_big_sur + bottle = described_class.new(TestballBottle.new, bottle_spec, tag) - describe "#sha256" do - it "works without cellar" do - checksums = { - arm64_big_sur: "deadbeef" * 8, - big_sur: "faceb00c" * 8, - catalina: "baadf00d" * 8, - mojave: "8badf00d" * 8, - } - - checksums.each_pair do |cat, digest| - bottle_spec.sha256(cat => digest) - tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(cat)) - expect(Checksum.new(digest)).to eq(tag_spec.checksum) - end - end - - it "works with cellar" do - checksums = [ - { cellar: :any_skip_relocation, tag: :arm64_big_sur, digest: "deadbeef" * 8 }, - { cellar: :any, tag: :big_sur, digest: "faceb00c" * 8 }, - { cellar: "/usr/local/Cellar", tag: :catalina, digest: "baadf00d" * 8 }, - { cellar: Homebrew::DEFAULT_CELLAR, tag: :mojave, digest: "8badf00d" * 8 }, - ] - - checksums.each do |checksum| - bottle_spec.sha256(cellar: checksum[:cellar], checksum[:tag] => checksum[:digest]) - tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(checksum[:tag])) - expect(Checksum.new(checksum[:digest])).to eq(tag_spec.checksum) - expect(checksum[:tag]).to eq(tag_spec.tag.to_sym) - checksum[:cellar] ||= Homebrew::DEFAULT_CELLAR - expect(checksum[:cellar]).to eq(tag_spec.cellar) - end - end - end - - describe "#compatible_locations?" do - it "checks if the bottle cellar is relocatable" do - expect(bottle_spec.compatible_locations?).to be false - end - end - - describe "#tag_to_cellar" do - it "returns the cellar for a tag" do - expect(bottle_spec.tag_to_cellar).to eq Utils::Bottles.tag.default_cellar - end - end - - %w[root_url rebuild].each do |method| - specify "##{method}" do - object = Object.new - bottle_spec.public_send(method, object) - expect(bottle_spec.public_send(method)).to eq(object) + expect(bottle.filename.to_s).to eq("testball_bottle--0.1.arm64_big_sur.bottle.tar.gz") end end end diff --git a/Library/Homebrew/test/software_spec/bottle_specification_spec.rb b/Library/Homebrew/test/software_spec/bottle_specification_spec.rb new file mode 100644 index 0000000000..98aa029bcd --- /dev/null +++ b/Library/Homebrew/test/software_spec/bottle_specification_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "software_spec" + +RSpec.describe BottleSpecification do + subject(:bottle_spec) { described_class.new } + + describe "#sha256" do + it "works without cellar" do + checksums = { + arm64_big_sur: "deadbeef" * 8, + big_sur: "faceb00c" * 8, + catalina: "baadf00d" * 8, + mojave: "8badf00d" * 8, + } + + checksums.each_pair do |cat, digest| + bottle_spec.sha256(cat => digest) + tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(cat)) + expect(Checksum.new(digest)).to eq(tag_spec.checksum) + end + end + + it "works with cellar" do + checksums = [ + { cellar: :any_skip_relocation, tag: :arm64_big_sur, digest: "deadbeef" * 8 }, + { cellar: :any, tag: :big_sur, digest: "faceb00c" * 8 }, + { cellar: "/usr/local/Cellar", tag: :catalina, digest: "baadf00d" * 8 }, + { cellar: Homebrew::DEFAULT_CELLAR, tag: :mojave, digest: "8badf00d" * 8 }, + ] + + checksums.each do |checksum| + bottle_spec.sha256(cellar: checksum[:cellar], checksum[:tag] => checksum[:digest]) + tag_spec = bottle_spec.tag_specification_for(Utils::Bottles::Tag.from_symbol(checksum[:tag])) + expect(Checksum.new(checksum[:digest])).to eq(tag_spec.checksum) + expect(checksum[:tag]).to eq(tag_spec.tag.to_sym) + checksum[:cellar] ||= Homebrew::DEFAULT_CELLAR + expect(checksum[:cellar]).to eq(tag_spec.cellar) + end + end + end + + describe "#compatible_locations?" do + it "checks if the bottle cellar is relocatable" do + expect(bottle_spec.compatible_locations?).to be false + end + end + + describe "#tag_to_cellar" do + it "returns the cellar for a tag" do + expect(bottle_spec.tag_to_cellar).to eq Utils::Bottles.tag.default_cellar + end + end + + %w[root_url rebuild].each do |method| + specify "##{method}" do + object = Object.new + bottle_spec.public_send(method, object) + expect(bottle_spec.public_send(method)).to eq(object) + end + end +end