10.4 KiB · text History b868907
defmodule GitGud.Packages.Formats.RpmTest do
  @moduledoc """
  The RPM adapter: publishing an `.rpm` and serving the rpm-md tree it
  produces.

  Packages are synthesised rather than downloaded. The header parser
  has its own file and is tested against a real openSUSE package there;
  what this file is about is the adapter — that a publish lands with
  the metadata the index needs, that `repomd.xml` agrees with the
  documents it points at, and that `_all` aggregates a namespace the
  way apt's does.

  The one property worth naming: `repomd.xml` pins a checksum, a size
  and a timestamp for each document, and dnf rejects a repository where
  those don't match what it downloads. So "the same request twice
  produces the same bytes" is not tidiness here, it is the difference
  between a working repository and one that fails on every second
  fetch.
  """
  use GitGud.DataCase, async: false

  import GitGud.ForgeFixtures
  import GitGud.RpmFixtures

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Rpm

  setup do
    {user, repo} = repository_fixture()
    %{user: user, repo: repo}
  end

  # ── driving the adapter ──────────────────────────────────────────────

  defp ctx(overrides) do
    Map.merge(
      %{
        path: [],
        conn: Plug.Test.conn(:get, "/"),
        method: "GET",
        trailing_slash?: false,
        base_url: "https://forge.test/pkg/rpm/acme/_all",
        principal: :anonymous
      },
      overrides
    )
  end

  defp publish(repo, body \\ nil),
    do: Rpm.store(repo, ctx(%{method: "POST", path: ["upload"]}), body || build_rpm())

  defp get(repo, path) do
    {:ok, _type, body} = Rpm.index(repo, ctx(%{path: path}))
    IO.iodata_to_binary(body)
  end

  defp gunzip(repo, path), do: repo |> get(path) |> :zlib.gunzip()

  # ── tests ────────────────────────────────────────────────────────────

  describe "publishing" do
    test "stores an RPM under its EVR with the metadata the index needs", %{repo: repo} do
      assert {:ok, "application/json", body} = publish(repo)
      reply = body |> IO.iodata_to_binary() |> Jason.decode!()

      assert reply["package"] == "koco"
      assert reply["version"] == "0.1.2-1"
      assert reply["arch"] == "x86_64"
      assert reply["filename"] == "koco-0.1.2-1.x86_64.rpm"

      assert [package] = Packages.list_packages(repo, "rpm")
      assert package.name == "koco"
      assert [file] = Packages.list_package_files(package)

      meta = file.metadata["rpm"]
      assert meta["license"] == "GPLv3"
      assert meta["installed_size"] == 4096
      assert meta["header_start"] > 96
    end

    test "an epoch shows up in the version, and zero doesn't", %{repo: repo} do
      {:ok, _, body} = publish(repo, build_rpm(%{epoch: 2}))
      assert Jason.decode!(IO.iodata_to_binary(body))["version"] == "2:0.1.2-1"

      # RPM treats a missing epoch and epoch 0 as the same thing, so
      # spelling the zero would make the common case read as `0:0.1.2-1`
      # for no gain.
      {:ok, _, plain} = publish(repo, build_rpm(%{epoch: 0, version: "0.2.0"}))
      assert Jason.decode!(IO.iodata_to_binary(plain))["version"] == "0.2.0-1"
    end

    test "refuses something that isn't an RPM", %{repo: repo} do
      assert {:error, {422, message}} = publish(repo, "hello, not an rpm")
      assert message =~ "lead magic"
    end

    test "refuses a second upload of the same filename", %{repo: repo} do
      {:ok, _, _} = publish(repo)
      assert {:error, :file_exists} = publish(repo, build_rpm(%{size: 9999}))
    end

    test "only POST writes", %{repo: repo} do
      assert {:error, {405, _}} = Rpm.store(repo, ctx(%{method: "PUT"}), build_rpm())
    end
  end

  describe "primary.xml" do
    setup %{repo: repo} do
      {:ok, _, _} = publish(repo)
      :ok
    end

    test "names the package the way dnf keys on it", %{repo: repo} do
      xml = gunzip(repo, ["repodata", "primary.xml.gz"])

      assert xml =~ ~s(packages="1")
      assert xml =~ "<name>koco</name>"
      assert xml =~ "<arch>x86_64</arch>"
      assert xml =~ ~s(<version epoch="0" ver="0.1.2" rel="1"/>)
    end

    test "the location is where the package actually is", %{repo: repo} do
      xml = gunzip(repo, ["repodata", "primary.xml.gz"])

      assert [_, href] = Regex.run(~r/<location href="([^"]+)"/, xml)

      # Not an assertion about the string: fetching it has to produce
      # the blob. A location that 404s is the one index bug a client
      # reports as "repository is broken" with no further detail.
      assert {:blob, file} = Rpm.index(repo, ctx(%{path: String.split(href, "/")}))
      assert file.filename == "koco-0.1.2-1.x86_64.rpm"
    end

    test "the checksum is the package's, and is marked as its id", %{repo: repo} do
      xml = gunzip(repo, ["repodata", "primary.xml.gz"])
      [package] = Packages.list_packages(repo, "rpm")
      [file] = Packages.list_package_files(package)

      assert xml =~ ~s(<checksum type="sha256" pkgid="YES">#{file.sha256}</checksum>)
    end

    test "drops rpmlib requirements the way createrepo does", %{repo: repo} do
      xml = gunzip(repo, ["repodata", "primary.xml.gz"])

      assert xml =~ ~s|<rpm:entry name="libc.so.6()(64bit)"|
      refute xml =~ "rpmlib(FileDigests)"
    end

    test "carries the header range, so dnf can range-request", %{repo: repo} do
      xml = gunzip(repo, ["repodata", "primary.xml.gz"])
      assert xml =~ ~r/<rpm:header-range start="\d+" end="\d+"\/>/
    end

    test "lists the files anything might depend on, and not the rest", %{repo: repo} do
      xml = gunzip(repo, ["repodata", "primary.xml.gz"])

      # createrepo's filter: bin/ and /etc land in primary, everything
      # else waits in filelists. Diverging would make our repository
      # resolve differently from a stock one.
      assert xml =~ "<file>/usr/bin/koco</file>"
      refute xml =~ "/usr/share/doc/koco/README.md"
    end
  end

  describe "filelists.xml" do
    test "has every file, including the ones primary filtered", %{repo: repo} do
      {:ok, _, _} = publish(repo)
      xml = gunzip(repo, ["repodata", "filelists.xml.gz"])

      assert xml =~ "<file>/usr/bin/koco</file>"
      assert xml =~ "<file>/usr/share/doc/koco/README.md</file>"
    end
  end

  describe "repomd.xml" do
    setup %{repo: repo} do
      {:ok, _, _} = publish(repo)
      %{xml: get(repo, ["repodata", "repomd.xml"])}
    end

    test "points at all three documents", %{xml: xml} do
      for kind <- ~w(primary filelists other) do
        assert xml =~ ~s(<data type="#{kind}">)
        assert xml =~ ~s(<location href="repodata/#{kind}.xml.gz"/>)
      end
    end

    test "its checksums match the bytes actually served", %{repo: repo, xml: xml} do
      # The whole reason the documents may not depend on the clock. A
      # mismatch here is what dnf reports as a corrupt repository.
      for kind <- ~w(primary filelists other) do
        [_, compressed, plain] =
          Regex.run(
            ~r/<data type="#{kind}">.*?<checksum type="sha256">(\w+)<.*?<open-checksum type="sha256">(\w+)</s,
            xml
          )

        served = get(repo, ["repodata", "#{kind}.xml.gz"])
        assert digest(served) == compressed
        assert digest(:zlib.gunzip(served)) == plain
      end
    end

    test "its sizes match too", %{repo: repo, xml: xml} do
      [_, size, open_size] =
        Regex.run(~r/<data type="primary">.*?<size>(\d+)<.*?<open-size>(\d+)</s, xml)

      served = get(repo, ["repodata", "primary.xml.gz"])
      assert Integer.to_string(byte_size(served)) == size
      assert Integer.to_string(byte_size(:zlib.gunzip(served))) == open_size
    end

    test "the revision is the newest publish, not now", %{repo: repo, xml: xml} do
      [_, revision] = Regex.run(~r|<revision>(\d+)</revision>|, xml)
      [package] = Packages.list_packages(repo, "rpm")
      [file] = Packages.list_package_files(package)

      expected = file.inserted_at |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix()
      assert String.to_integer(revision) == expected
    end

    test "the same request twice is the same bytes", %{repo: repo, xml: xml} do
      assert get(repo, ["repodata", "repomd.xml"]) == xml

      assert get(repo, ["repodata", "primary.xml.gz"]) ==
               get(repo, ["repodata", "primary.xml.gz"])
    end

    test "an empty repository is still a valid one", %{user: user} do
      {_user, empty} = repository_fixture(user)
      xml = get(empty, ["repodata", "repomd.xml"])

      assert xml =~ "<revision>0</revision>"
      assert gunzip(empty, ["repodata", "primary.xml.gz"]) =~ ~s(packages="0")
    end
  end

  describe "signing" do
    test "repomd.xml.asc verifies against the key repomd.xml.key serves", %{repo: repo} do
      {:ok, _, _} = publish(repo)

      document = get(repo, ["repodata", "repomd.xml"])

      {:ok, "application/pgp-signature", signature} =
        Rpm.index(repo, ctx(%{path: ["repodata", "repomd.xml.asc"]}))

      {:ok, "application/pgp-keys", key} =
        Rpm.index(repo, ctx(%{path: ["repodata", "repomd.xml.key"]}))

      # This is exactly what `repo_gpgcheck=1` does.
      assert {:ok, true} =
               GitGud.Packages.Apt.Native.verify_detached(key, document, signature)
    end

    test "the key is the namespace's, not the repository's", %{user: user, repo: repo} do
      {:ok, %{public_pem: from_repo}} = Packages.signing_key(repo, "rpm")
      {:ok, %{public_pem: from_namespace}} = Packages.signing_key(user, "rpm")

      # One `gpgkey=` covers every repository the namespace publishes,
      # and covers `_all` as well.
      assert from_repo == from_namespace
    end

    test "it is a different key from the deb one", %{user: user} do
      {:ok, %{public_pem: rpm}} = Packages.signing_key(user, "rpm")
      {:ok, %{public_pem: deb}} = Packages.signing_key(user, "deb")

      refute rpm == deb
    end
  end

  describe "browsing" do
    test "the root lists the tree apt-style", %{repo: repo} do
      {:ok, _, _} = publish(repo)
      html = get(repo, [])

      assert html =~ "repodata/"
      assert html =~ "Packages/"
      assert html =~ "dnf reads this tree"
    end

    test "a path that serves nothing is a 404, not an empty page", %{repo: repo} do
      assert {:error, :not_found} = Rpm.index(repo, ctx(%{path: ["nope"]}))
    end
  end

  defp digest(bytes), do: :sha256 |> :crypto.hash(bytes) |> Base.encode16(case: :lower)
end