# typed: strict
# frozen_string_literal: true

require "date"
require "json"
require "utils/popen"
require "utils/github/api"
require "exceptions"
require "system_command"
require "utils/output"

module Homebrew
  module Attestation
    extend SystemCommand::Mixin
    extend Utils::Output::Mixin

    # @api private
    HOMEBREW_CORE_REPO = "Homebrew/homebrew-core"

    # @api private
    BACKFILL_REPO = "trailofbits/homebrew-brew-verify"

    # 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: <https://github.com/trailofbits/homebrew-brew-verify/attestations>.
    #
    # 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

    # Raised when the attestation was not found.
    #
    # @api private
    class MissingAttestationError < RuntimeError; end

    # Raised when attestation verification fails.
    #
    # @api private
    class InvalidAttestationError < RuntimeError; end

    # Raised if attestation verification cannot continue due to missing
    # credentials.
    #
    # @api private
    class GhAuthNeeded < RuntimeError; end

    # Raised if attestation verification cannot continue due to invalid
    # credentials.
    #
    # @api private
    class GhAuthInvalid < RuntimeError; end

    # Raised if attestation verification cannot continue due to `gh`
    # being incompatible with attestations, typically because it's too old.
    #
    # @api private
    class GhIncompatible < RuntimeError; end

    # Returns a path to a suitable `gh` executable for attestation verification.
    #
    # @api private
    sig { returns(Pathname) }
    def self.gh_executable
      @gh_executable ||= T.let(nil, T.nilable(Pathname))
      return @gh_executable if @gh_executable

      # NOTE: We set HOMEBREW_NO_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_NO_VERIFY_ATTESTATIONS: "1") do
        ensure_executable!("gh", reason: "verifying attestations", latest: true)
      end
    end

    # Prioritize installing `gh` first if it's in the formula list
    # or check for the existence of the `gh` executable elsewhere.
    #
    # This ensures that a valid version of `gh` is installed before
    # we use it to check the attestations of any other formulae we
    # want to install.
    #
    # @api private
    sig { params(formulae: T::Array[Formula]).returns(T::Array[Formula]) }
    def self.sort_formulae_for_install(formulae)
      if (gh = formulae.find { |f| f.full_name == "gh" })
        [gh] | formulae
      else
        Homebrew::Attestation.gh_executable
        formulae
      end
    end

    # Verifies the given bottle against a cryptographic attestation of build provenance.
    #
    # The provenance is verified as originating from `signing_repository`, which is a `String`
    # that should be formatted as a GitHub `owner/repository`.
    #
    # 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.
    # @raise [GhAuthNeeded] on any authentication failures
    # @raise [InvalidAttestationError] on any verification failures
    #
    # @api private
    sig {
      params(bottle: Bottle, signing_repo: String,
             signing_workflow: T.nilable(String), subject: T.nilable(String)).returns(T::Hash[String, T.untyped])
    }
    def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil)
      cmd = ["attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format",
             "json"]

      cmd += ["--cert-identity", signing_workflow] if signing_workflow.present?

      # Fail early if we have no credentials. The command below invariably
      # fails without them, so this saves us an unnecessary subshell.
      credentials = GitHub::API.credentials
      raise GhAuthNeeded, "missing credentials" if credentials.blank?

      begin
        result = system_command!(gh_executable, args: cmd,
                                 env: { "GH_TOKEN" => credentials, "GH_HOST" => "github.com" },
                                 secrets: [credentials], print_stderr: false, chdir: HOMEBREW_TEMP)
      rescue ErrorDuringExecution => e
        if e.exitstatus == 1 && e.stderr.include?("unknown command")
          raise GhIncompatible, "gh CLI is incompatible with attestations"
        end

        # Even if we have credentials, they may be invalid or malformed.
        if e.exitstatus == 4 || e.stderr.include?("HTTP 401: Bad credentials")
          raise GhAuthInvalid, "invalid credentials"
        end

        # The API used to return 404 but now can return 200 with an empty array.
        # We match the no attestation case precisely as there are similarly worded errors.
        if e.stderr.include?("HTTP 404: Not Found") || e.stderr.match?(/: no attestations found\R/)
          raise MissingAttestationError, "attestation not found: #{e}"
        end

        raise InvalidAttestationError, "attestation verification failed: #{e}"
      end

      begin
        attestations = JSON.parse(result.stdout)
      rescue JSON::ParserError
        raise InvalidAttestationError, "attestation verification returned malformed JSON"
      end

      # `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(s) contain the bottle's name.
      # As of 2024-12-04 GitHub's Artifact Attestation feature can put multiple subjects
      # in a single attestation, so we check every subject in each attestation
      # and select the first attestation with a matching subject.
      # In particular, this happens with v2.0.0 and later of the
      # `actions/attest` action.
      subject = bottle.filename.to_s if subject.blank?

      attestation = if bottle.tag.to_sym == :all
        # :all-tagged bottles are created by `brew bottle --merge`, and are not directly
        # bound to their own filename (since they're created by deduplicating other filenames).
        # To verify these, we parse each attestation subject and look for one with a matching
        # formula (name, version), but not an exact tag match.
        # This is sound insofar as the signature has already been verified. However,
        # longer term, we should also directly attest to `:all`-tagged bottles.
        attestations.find do |a|
          candidate_subjects = a.dig("verificationResult", "statement", "subject")
          candidate_subjects.any? do |candidate|
            candidate["name"].start_with? "#{bottle.filename.name}--#{bottle.filename.version}"
          end
        end
      else
        attestations.find do |a|
          candidate_subjects = a.dig("verificationResult", "statement", "subject")
          candidate_subjects.any? { |candidate| candidate["name"] == subject }
        end
      end

      raise InvalidAttestationError, "no attestation matches subject: #{subject}" if attestation.blank?

      attestation
    end

    ATTESTATION_MAX_RETRIES = 5

    # 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.
    #
    # @return [Hash] the JSON-decoded response
    # @raise [GhAuthNeeded] on any authentication failures
    # @raise [InvalidAttestationError] on any verification failures
    #
    # @api private
    sig { params(bottle: Bottle).returns(T::Hash[String, T.untyped]) }
    def self.check_core_attestation(bottle)
      begin
        # Ideally, we would also constrain the signing workflow here, but homebrew-core
        # currently uses multiple signing workflows to produce bottles
        # (e.g. `dispatch-build-bottle.yml`, `dispatch-rebottle.yml`, etc.).
        #
        # We could check each of these (1) explicitly (slow), (2) by generating a pattern
        # to pass into `--cert-identity-regex` (requires us to build up a Go-style regex),
        # or (3) by checking the resulting JSON for the expected signing workflow.
        #
        # Long term, we should probably either do (3) *or* switch to a single reusable
        # workflow, which would then be our sole identity. However, GitHub's
        # attestations currently do not include reusable workflow state by default.
        attestation = check_attestation bottle, HOMEBREW_CORE_REPO
        return attestation
      rescue MissingAttestationError
        odebug "falling back on backfilled attestation for #{bottle.filename}"

        # 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 = if EnvConfig.bottle_domain_custom?
          # If our bottle is coming from a mirror, we need to recompute the expected
          # non-mirror URL to make the hash match.
          checksum = bottle.resource.checksum
          odie "#{bottle.resource.name} checksum is nil" if checksum.nil?
          path, = Utils::Bottles.path_resolved_basename HOMEBREW_BOTTLE_DEFAULT_DOMAIN, bottle.name,
                                                        checksum, bottle.filename
          url = "#{HOMEBREW_BOTTLE_DEFAULT_DOMAIN}/#{path}"

          Digest::SHA256.hexdigest(url)
        else
          Digest::SHA256.hexdigest(bottle.url)
        end
        subject = "#{url_sha256}--#{bottle.filename}"

        # We don't pass in a signing workflow for backfill signatures because
        # some backfilled bottle signatures were signed from the 'backfill'
        # branch, and others from 'main' of trailofbits/homebrew-brew-verify
        # so the signing workflow is slightly different which causes some bottles to incorrectly
        # fail when checking their attestation. This shouldn't meaningfully affect security
        # because if somehow someone could generate false backfill attestations
        # from a different workflow we will still catch it because the
        # attestation would have been generated after our cutoff date.
        backfill_attestation = check_attestation bottle, BACKFILL_REPO, nil, subject
        timestamp = backfill_attestation.dig("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
    rescue InvalidAttestationError
      @attestation_retry_count ||= T.let(Hash.new(0), T.nilable(T::Hash[Bottle, Integer]))
      raise if @attestation_retry_count[bottle] >= ATTESTATION_MAX_RETRIES

      sleep_time = 3 ** @attestation_retry_count[bottle]
      opoo "Failed to verify attestation. Retrying in #{sleep_time}s..."
      sleep sleep_time if ENV["HOMEBREW_TESTS"].blank?
      @attestation_retry_count[bottle] += 1
      retry
    end
  end
end
