9.7 KiB · text History b868907
defmodule GitGud.Packages.Formats.Hex do
  @moduledoc """
  A Hex registry v2 repository, per git repository.

      mix hex.repo add <name> https://<host>/pkg/hex/<owner>/<repo> \\
        --public-key=<(curl -s https://<host>/pkg/hex/<owner>/<repo>/public_key)

      # then, in deps:
      {:internal_thing, "~> 1.0", repo: "<name>"}

  Paths below the repository root:

      /names                          every package, signed
      /versions                       every version of every package, signed
      /packages/<NAME>                one package's releases, signed
      /tarballs/<NAME>-<VERSION>.tar  the published tarball
      /public_key                     the key that verifies the above
      /publish                        POST a tarball here

  ## Building the registry rather than rebuilding it

  The documented way to self-host is `mix hex.registry build`, which
  regenerates the whole directory from a folder of tarballs. That makes
  publishing a rebuild, and a rebuild is O(everything) — so this
  encodes the protobufs on request instead, straight out of Postgres.
  Publishing becomes an insert.

  ## The signature

  Every resource is a `Signed{payload, signature}`, and the signature
  is an RSA signature over the **SHA-512 digest of the payload**, both
  unencoded. Then the encoded `Signed` is gzipped — and gzipped as
  *content*, not as a transfer encoding: `mix` gunzips the body itself,
  so a `Content-Encoding: gzip` header would have the HTTP client
  decompress it first and leave `mix` with something it can't read.

  ## Repository name

  `mix` refuses a payload whose `repository` field doesn't match the
  name the repo was added under, so the name isn't cosmetic. It is
  derived here (`<owner>_<repo>`), and the packages page prints the
  exact `mix hex.repo add` line to paste.
  """

  @behaviour GitGud.Packages.Format

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Hex.Pb
  alias GitGud.Packages.Formats.Hex.Tarball
  alias GitGud.Packages.Package
  alias GitGud.Packages.PackageFile
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage

  @format "hex"
  @signed "application/octet-stream"

  @doc """
  The name `mix hex.repo add` must be given for this repository.

  Underscore-joined rather than hyphen-joined because repository names
  routinely contain hyphens, and `acme-web-utils` shouldn't be
  ambiguous about where the owner stops.
  """
  def repo_name(%Repository{} = repo) do
    repo = GitGud.Repo.preload(repo, [:owner, :organization])
    "#{Storage.repo_handle(repo)}_#{repo.name}"
  end

  @impl true
  def content_type(_filename), do: "application/octet-stream"

  # ── reads ───────────────────────────────────────────────────────────

  @impl true
  def index(repo, ctx)

  def index(repo, %{path: ["public_key"]}) do
    with {:ok, %{public_pem: pem}} <- Packages.signing_key(repo, @format) do
      {:ok, "text/plain; charset=utf-8", pem}
    end
  end

  def index(repo, %{path: ["names"]}) do
    packages =
      repo
      |> Packages.list_packages(@format)
      |> Enum.map(&%Pb.Names.Package{name: &1.name})

    sign(repo, Pb.Names, %Pb.Names{repository: repo_name(repo), packages: packages})
  end

  def index(repo, %{path: ["versions"]}) do
    packages =
      repo
      |> Packages.list_packages(@format)
      |> Enum.map(fn package ->
        releases = sorted_versions(package)

        %Pb.Versions.Package{
          name: package.name,
          versions: Enum.map(releases, & &1.version),
          # Indexes into `versions`, not version strings — the spec
          # keeps the list positional.
          retired: retired_indexes(releases)
        }
      end)

    sign(repo, Pb.Versions, %Pb.Versions{repository: repo_name(repo), packages: packages})
  end

  def index(repo, %{path: ["packages", name]}) do
    case Packages.get_package(repo, @format, name) do
      nil ->
        {:error, :not_found}

      %Package{} = package ->
        releases = package |> sorted_versions() |> Enum.map(&release(&1))

        sign(repo, Pb.Package, %Pb.Package{
          repository: repo_name(repo),
          name: package.name,
          releases: releases
        })
    end
  end

  def index(repo, %{path: ["tarballs", filename]}) do
    case find_tarball(repo, filename) do
      %PackageFile{} = file -> {:blob, file}
      nil -> {:error, :not_found}
    end
  end

  def index(_repo, _ctx), do: {:error, :not_found}

  defp find_tarball(repo, filename) do
    repo
    |> Packages.list_packages(@format)
    |> Enum.find_value(fn package -> Packages.get_package_file(package, filename) end)
  end

  # Hex resolves against this list directly, so ordering is the
  # registry's job. Unparseable versions sort last by string rather
  # than crashing the whole index.
  defp sorted_versions(%Package{} = package) do
    package
    |> Packages.list_versions()
    |> Enum.sort(fn a, b ->
      case {Version.parse(a.version), Version.parse(b.version)} do
        {{:ok, va}, {:ok, vb}} -> Version.compare(va, vb) != :gt
        {{:ok, _}, :error} -> true
        {:error, {:ok, _}} -> false
        _ -> a.version <= b.version
      end
    end)
  end

  defp retired_indexes(releases) do
    releases
    |> Enum.with_index()
    |> Enum.filter(fn {release, _index} -> release.yanked end)
    |> Enum.map(fn {_release, index} -> index end)
  end

  defp release(version) do
    metadata = version.metadata || %{}

    %Pb.Release{
      version: version.version,
      inner_checksum: decode_checksum(metadata["inner_checksum"]),
      outer_checksum: decode_checksum(metadata["outer_checksum"]),
      dependencies: Enum.map(metadata["dependencies"] || [], &dependency/1),
      retired: retirement(version)
    }
  end

  # Checksums travel as hex in the database and as raw bytes on the
  # wire.
  defp decode_checksum(nil), do: <<>>

  defp decode_checksum(hex) when is_binary(hex) do
    case Base.decode16(hex, case: :mixed) do
      {:ok, raw} -> raw
      :error -> <<>>
    end
  end

  defp dependency(dep) when is_map(dep) do
    %Pb.Dependency{
      package: dep["package"] || dep["name"] || "",
      requirement: dep["requirement"] || "",
      optional: dep["optional"] || false,
      app: dep["app"],
      repository: dep["repository"]
    }
  end

  # Retirement is Hex's yank: the release stays resolvable and `mix`
  # warns. We only ever record RETIRED_OTHER — the finer reasons are a
  # publisher's claim, and nothing in our publish path establishes one.
  defp retirement(%{yanked: true} = version),
    do: %Pb.RetirementStatus{reason: :RETIRED_OTHER, message: version.yanked_reason}

  defp retirement(_version), do: nil

  # ── signing ─────────────────────────────────────────────────────────

  defp sign(repo, module, message) do
    with {:ok, %{private_pem: pem}} <- Packages.signing_key(repo, @format) do
      payload = module.encode(message)

      signed =
        Pb.Signed.encode(%Pb.Signed{
          payload: payload,
          signature: rsa_sign(payload, pem)
        })

      {:ok, @signed, :zlib.gzip(signed)}
    end
  end

  # "An RSA signature over the SHA-512 digest of the payload, both
  # unencoded" — which is exactly what :public_key.sign/3 computes,
  # digest included.
  defp rsa_sign(payload, private_pem) do
    [entry] = :public_key.pem_decode(private_pem)
    :public_key.sign(payload, :sha512, :public_key.pem_entry_decode(entry))
  end

  @doc """
  Verify a signed resource against a public key PEM. Not used to serve
  anything — it exists so the tests can check what a client will check,
  rather than checking that we called our own signer.
  """
  def verify(signed_gzip, public_pem) do
    %Pb.Signed{payload: payload, signature: signature} =
      signed_gzip |> :zlib.gunzip() |> Pb.Signed.decode()

    [entry] = :public_key.pem_decode(public_pem)

    {payload,
     :public_key.verify(payload, :sha512, signature, :public_key.pem_entry_decode(entry))}
  end

  # ── publishing ──────────────────────────────────────────────────────

  @impl true
  def store(repo, ctx, body)

  def store(repo, %{method: "POST", path: path} = ctx, body)
      when is_binary(body) and (path == ["publish"] or path == []) do
    with {:ok, meta} <- Tarball.parse(body) do
      filename = "#{meta.name}-#{meta.version}.tar"

      attrs = %{
        filename: filename,
        body: body,
        content_type: content_type(filename),
        metadata: %{"inner_checksum" => meta.inner_checksum},
        version_metadata: %{
          "inner_checksum" => meta.inner_checksum,
          "outer_checksum" => Base.encode16(:crypto.hash(:sha256, body), case: :lower),
          "dependencies" => meta.dependencies,
          "app" => meta.app,
          "description" => meta.description
        },
        uploader: uploader(ctx)
      }

      case Packages.publish_file(repo, @format, meta.name, meta.version, attrs) do
        {:ok, _package, _version, _file} ->
          {:ok, "application/json",
           Jason.encode_to_iodata!(%{"name" => meta.name, "version" => meta.version})}

        {:error, :file_exists} ->
          {:error, :file_exists}

        {:error, reason} ->
          {:error, {400, inspect(reason)}}
      end
    end
  end

  def store(_repo, %{method: "POST"}, _body),
    do: {:error, {400, "POST the raw Hex tarball to /publish"}}

  def store(_repo, %{method: method}, _body),
    do: {:error, {405, "#{method} is not supported by the Hex registry"}}

  defp uploader(%{principal: {:user, user, _scopes}}), do: user
  defp uploader(_ctx), do: nil
end