12.1 KiB · text History 4869a7f
defmodule GitGudWeb.HexRegistryTest do
  @moduledoc """
  Phase 2: the Hex registry v2 repository, over HTTP.

  The assertions that matter here are the ones `mix` makes: the
  payload decodes, the signature verifies against the published public
  key, and the `repository` field matches the name the repo was added
  under — `mix` rejects the registry outright if any of those is off.
  """
  use GitGudWeb.ConnCase, async: false

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Hex, as: HexFormat
  alias GitGud.Packages.Formats.Hex.Pb
  alias GitGud.Packages.Formats.Hex.Tarball
  alias GitGud.Repositories.Storage

  import GitGud.ForgeFixtures

  setup do
    {user, repo} = repository_fixture(%{visibility: "public"})
    {:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"])

    {:ok,
     user: user,
     repo: repo,
     token: token,
     root: "/pkg/hex/#{Storage.owner_handle(user)}/#{repo.name}"}
  end

  # A published Hex tarball: an uncompressed .tar of VERSION, CHECKSUM,
  # metadata.config and contents.tar.gz.
  defp hex_tarball(name, version, opts \\ []) do
    requirements = Keyword.get(opts, :requirements, [])
    contents = :zlib.gzip("the package source")

    config =
      [
        ~s({<<"name">>, <<"#{name}">>}.),
        ~s({<<"version">>, <<"#{version}">>}.),
        ~s({<<"app">>, <<"#{name}">>}.),
        ~s({<<"description">>, <<"a test package">>}.),
        requirements_term(requirements)
      ]
      |> Enum.join("\n")

    inner = :crypto.hash(:sha256, "3" <> config <> contents)

    members = [
      {~c"VERSION", "3"},
      {~c"CHECKSUM", Base.encode16(inner, case: :upper)},
      {~c"metadata.config", config},
      {~c"contents.tar.gz", contents}
    ]

    path = Path.join(System.tmp_dir!(), "hex-#{System.unique_integer([:positive])}.tar")
    :ok = :erl_tar.create(String.to_charlist(path), members, [])
    File.read!(path)
  end

  defp requirements_term([]), do: ~s({<<"requirements">>, []}.)

  defp requirements_term(reqs) do
    entries =
      Enum.map_join(reqs, ",", fn {pkg, req} ->
        ~s([{<<"name">>,<<"#{pkg}">>},{<<"app">>,<<"#{pkg}">>},) <>
          ~s({<<"optional">>,false},{<<"requirement">>,<<"#{req}">>},) <>
          ~s({<<"repository">>,<<"hexpm">>}])
      end)

    ~s({<<"requirements">>, [#{entries}]}.)
  end

  defp publish(conn, root, token, tarball) do
    conn
    |> put_req_header("authorization", token)
    |> put_req_header("content-type", "application/octet-stream")
    |> post(root <> "/publish", tarball)
  end

  defp public_key(conn, root) do
    get(conn, root <> "/public_key").resp_body
  end

  # Exactly what a client does: gunzip, decode Signed, verify the RSA
  # signature over the SHA-512 digest of the payload, then decode.
  defp fetch_signed(conn, root, path, module, message) do
    pem = public_key(build_conn(), root)
    body = get(conn, root <> path).resp_body

    {payload, verified?} = HexFormat.verify(body, pem)
    assert verified?, "#{path} did not verify against the published public key"

    {module.decode(payload), message}
    |> elem(0)
  end

  describe "/public_key" do
    test "serves a PEM the registry's own signatures verify against", %{conn: conn, root: root} do
      pem = public_key(conn, root)

      assert pem =~ "-----BEGIN PUBLIC KEY-----"
      assert [{:SubjectPublicKeyInfo, _, _}] = :public_key.pem_decode(pem)
    end

    test "is stable across requests — a rotating key breaks every consumer", %{
      conn: conn,
      root: root
    } do
      assert public_key(conn, root) == public_key(build_conn(), root)
    end
  end

  describe "/names" do
    test "lists published packages, signed", %{conn: conn, repo: repo, root: root, token: token} do
      publish(conn, root, token, hex_tarball("widget", "1.0.0"))

      names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names)

      assert names.repository == HexFormat.repo_name(repo)
      assert Enum.map(names.packages, & &1.name) == ["widget"]
    end

    test "the repository field matches the name mix must add it under", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      publish(conn, root, token, hex_tarball("widget", "1.0.0"))

      names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names)

      # `mix` rejects the whole registry when these disagree.
      assert names.repository == HexFormat.repo_name(repo)
      assert names.repository =~ repo.name
    end

    test "a tampered payload fails verification", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, hex_tarball("widget", "1.0.0"))

      pem = public_key(build_conn(), root)

      %Pb.Signed{} =
        signed =
        get(build_conn(), root <> "/names").resp_body |> :zlib.gunzip() |> Pb.Signed.decode()

      forged =
        %Pb.Signed{signed | payload: Pb.Names.encode(%Pb.Names{repository: "evil", packages: []})}
        |> Pb.Signed.encode()
        |> :zlib.gzip()

      assert {_payload, false} = HexFormat.verify(forged, pem)
    end
  end

  describe "/versions" do
    test "lists versions in order with retired entries by index", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      for v <- ["1.0.0", "1.2.0", "1.10.0"] do
        publish(build_conn(), root, token, hex_tarball("widget", v))
      end

      pkg = Packages.get_package(repo, "hex", "widget")
      {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.2.0"), true, "bad build")

      versions = fetch_signed(conn, root, "/versions", Pb.Versions, Pb.Versions)

      assert [package] = versions.packages
      # Semver order, not lexicographic — 1.10.0 sorts after 1.2.0.
      assert package.versions == ["1.0.0", "1.2.0", "1.10.0"]
      # Positional: retirement points at an index in `versions`.
      assert package.retired == [1]
    end
  end

  describe "/packages/<name>" do
    test "carries checksums and dependencies", %{conn: conn, root: root, token: token} do
      tarball = hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
      publish(conn, root, token, tarball)

      package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package)

      assert [release] = package.releases
      assert release.version == "1.0.0"

      # Checksums go out as raw bytes, not hex.
      assert byte_size(release.inner_checksum) == 32
      assert release.outer_checksum == :crypto.hash(:sha256, tarball)

      assert [dep] = release.dependencies
      assert dep.package == "jason"
      assert dep.requirement == "~> 1.0"
      refute dep.optional
    end

    test "a yanked release is retired, not removed", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      publish(conn, root, token, hex_tarball("widget", "1.0.0"))
      pkg = Packages.get_package(repo, "hex", "widget")
      {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.0.0"), true, "bad build")

      package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package)

      assert [%Pb.Release{retired: retired}] = package.releases
      assert retired.reason == :RETIRED_OTHER
      assert retired.message == "bad build"
    end

    test "404 for an unpublished package", %{conn: conn, root: root} do
      assert get(conn, root <> "/packages/nothing").status == 404
    end
  end

  describe "/tarballs" do
    test "serves the published bytes back verbatim", %{conn: conn, root: root, token: token} do
      tarball = hex_tarball("widget", "1.0.0")
      publish(conn, root, token, tarball)

      conn = get(build_conn(), root <> "/tarballs/widget-1.0.0.tar")

      assert conn.status == 200
      assert conn.resp_body == tarball
    end
  end

  describe "publishing" do
    test "derives name, version and dependencies from the tarball", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      conn =
        publish(
          conn,
          root,
          token,
          hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
        )

      assert conn.status == 200
      assert %{"name" => "widget", "version" => "1.0.0"} = Jason.decode!(conn.resp_body)

      pkg = Packages.get_package(repo, "hex", "widget")
      version = Packages.get_version(pkg, "1.0.0")

      assert [%{"package" => "jason", "requirement" => "~> 1.0"}] =
               version.metadata["dependencies"]
    end

    test "rejects something that isn't a tar", %{conn: conn, root: root, token: token} do
      conn = publish(conn, root, token, "definitely not a tarball")

      assert conn.status == 400
      assert conn.resp_body =~ "tar"
    end

    test "rejects a tar with no metadata.config", %{conn: conn, root: root, token: token} do
      path = Path.join(System.tmp_dir!(), "empty-#{System.unique_integer([:positive])}.tar")
      :ok = :erl_tar.create(String.to_charlist(path), [{~c"VERSION", "3"}], [])

      conn = publish(conn, root, token, File.read!(path))

      assert conn.status == 400
      assert conn.resp_body =~ "metadata.config"
    end

    test "an anonymous publish is refused", %{conn: conn, root: root} do
      conn =
        conn
        |> put_req_header("content-type", "application/octet-stream")
        |> post(root <> "/publish", hex_tarball("widget", "1.0.0"))

      assert conn.status == 401
    end

    test "409 on republishing a version with different bytes", %{
      conn: conn,
      root: root,
      token: token
    } do
      publish(conn, root, token, hex_tarball("widget", "1.0.0"))

      conn =
        publish(
          build_conn(),
          root,
          token,
          hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
        )

      assert conn.status == 409
    end
  end

  describe "Tarball.parse/1" do
    test "reads the inner checksum out of CHECKSUM rather than recomputing it" do
      tarball = hex_tarball("widget", "1.0.0")

      assert {:ok, meta} = Tarball.parse(tarball)
      assert meta.name == "widget"
      assert meta.version == "1.0.0"
      assert meta.app == "widget"
      assert meta.description == "a test package"
      assert String.match?(meta.inner_checksum, ~r/\A[0-9a-f]{64}\z/)
    end

    test "reads the older proplist-keyed requirements shape too" do
      contents = :zlib.gzip("source")

      config = """
      {<<"name">>, <<"legacy">>}.
      {<<"version">>, <<"0.1.0">>}.
      {<<"requirements">>, [{<<"jason">>, [{<<"requirement">>, <<"~> 1.0">>}, {<<"optional">>, false}]}]}.
      """

      inner = :crypto.hash(:sha256, "3" <> config <> contents)

      path = Path.join(System.tmp_dir!(), "legacy-#{System.unique_integer([:positive])}.tar")

      :ok =
        :erl_tar.create(
          String.to_charlist(path),
          [
            {~c"VERSION", "3"},
            {~c"CHECKSUM", Base.encode16(inner, case: :upper)},
            {~c"metadata.config", config},
            {~c"contents.tar.gz", contents}
          ],
          []
        )

      assert {:ok, meta} = Tarball.parse(File.read!(path))
      assert [%{"package" => "jason", "requirement" => "~> 1.0"}] = meta.dependencies
    end

    test "metadata.config is parsed as data — a function call is not evaluated" do
      contents = :zlib.gzip("source")
      config = "{<<\"name\">>, os:cmd(\"touch /tmp/pwned\")}.\n"
      inner = :crypto.hash(:sha256, "3" <> config <> contents)

      path = Path.join(System.tmp_dir!(), "evil-#{System.unique_integer([:positive])}.tar")

      :ok =
        :erl_tar.create(
          String.to_charlist(path),
          [
            {~c"VERSION", "3"},
            {~c"CHECKSUM", Base.encode16(inner, case: :upper)},
            {~c"metadata.config", config},
            {~c"contents.tar.gz", contents}
          ],
          []
        )

      assert {:error, {400, _}} = Tarball.parse(File.read!(path))
    end
  end

  describe "visibility" do
    test "a private registry challenges an anonymous client" do
      {user, private} = repository_fixture(%{visibility: "private"})
      root = "/pkg/hex/#{Storage.owner_handle(user)}/#{private.name}"

      conn = get(build_conn(), root <> "/names")

      assert conn.status == 401
      assert [_] = get_resp_header(conn, "www-authenticate")
    end
  end
end