13.0 KiB · text History ff5c190
defmodule GitGud.Packages.Formats.Rpm do
  @moduledoc """
  An rpm-md repository, per git repository *and* per namespace.

      # everything acme publishes — one repo file, one key
      [acme]
      name=acme
      baseurl=https://<host>/pkg/rpm/acme/_all
      gpgcheck=1
      repo_gpgcheck=1
      gpgkey=https://<host>/pkg/_keys/rpm/acme

      # or one repository on its own
      baseurl=https://<host>/pkg/rpm/acme/tools

  The shape is the apt repository's: static documents over HTTP with
  one detached OpenPGP signature over the index, generated per request
  from what the caller may read. `_all` aggregates the namespace for
  the same reason it does there — an administrator thinks in
  publishers, not repositories.

  Paths below either root:

      /repodata/repomd.xml         the index of indices, and what dnf fetches first
      /repodata/repomd.xml.asc     detached signature over it — repo_gpgcheck=1
      /repodata/repomd.xml.key     the public key, for gpgkey=
      /repodata/primary.xml.gz     names, versions, dependencies
      /repodata/filelists.xml.gz   every file in every package
      /repodata/other.xml.gz       changelogs (empty)
      /Packages/<repo>/<p>/<file>.rpm
      /upload                      POST an .rpm (per repo)

  ## Why the whole index is regenerated per request

  Because the caller's visibility is part of the answer. A token that
  can read one private repository and a stranger who can read none must
  get different — and separately correct — indices from the same URL,
  and a cached document has no caller.

  The cost is bounded by what is stored at upload: `Rpm.Metadata`
  summarises each package's header once, so building `primary.xml`
  reads database rows, never `.rpm` blobs.

  ## repomd.xml must agree with itself

  `repomd.xml` carries a checksum, a size and a timestamp for each of
  the three documents, and dnf rejects a repository where they don't
  match what it downloads. So nothing in the generated documents may
  depend on the clock: the repository's `revision` and every
  `timestamp` is the newest publish time in it, not `now`, and gzip is
  used in the mode that writes a zero mtime. The same mistake sank the
  first F-Droid index, which pinned a hash it then regenerated.

  ## Signing

  `repomd.xml.asc` is a detached OpenPGP signature, which is exactly
  what `Release.gpg` is — same NIF, same per-namespace key, and a
  namespace that already publishes debs signs its RPMs with the key
  its users have imported.

  Signing individual packages is a different job and deliberately not
  done here: `gpgcheck=1` verifies a signature baked into the `.rpm` at
  build time, by whoever built it. The forge signs what the forge
  produces, which is the index.
  """

  @behaviour GitGud.Packages.Format

  alias GitGud.Packages
  alias GitGud.Packages.Apt.Native
  alias GitGud.Packages.Formats.Listing
  alias GitGud.Packages.Formats.Rpm.Header
  alias GitGud.Packages.Formats.Rpm.Metadata
  alias GitGud.Packages.PackageFile
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage

  @format "rpm"

  # The three documents, in the order createrepo writes them into
  # repomd.xml. dnf doesn't care about the order; a human diffing our
  # repomd.xml against a mirror's does.
  @documents [:primary, :filelists, :other]

  @impl true
  def content_type(_filename), do: "application/x-rpm"

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

  @impl true
  def index(repo, ctx), do: serve([repo], repo_label(repo), repo, ctx)

  @doc """
  The namespace-wide repository at `_all`, built from every repository
  in the namespace the caller may read and signed with the namespace's
  key — the same one that signs each repository on its own, so one
  `gpgkey=` covers both ways of consuming it.
  """
  @impl true
  def namespace_index(namespace, repos, ctx),
    do: serve(repos, Storage.owner_handle(namespace), namespace, ctx)

  defp serve(repos, label, signing_scope, ctx)

  defp serve(_repos, _label, scope, %{path: ["repodata", "repomd.xml.key"]}) do
    with {:ok, %{public_pem: armored}} <- Packages.signing_key(scope, @format) do
      {:ok, "application/pgp-keys", armored}
    end
  end

  defp serve(repos, label, _scope, %{path: ["repodata", "repomd.xml"]}),
    do: {:ok, "application/xml", repomd(repos, label)}

  defp serve(repos, label, scope, %{path: ["repodata", "repomd.xml.asc"]}) do
    with {:ok, %{private_pem: secret}} <- Packages.signing_key(scope, @format),
         {:ok, signature} <-
           Native.sign_detached(secret, IO.iodata_to_binary(repomd(repos, label))) do
      {:ok, "application/pgp-signature", signature}
    else
      {:error, reason} -> {:error, {500, "could not sign repomd.xml: #{inspect(reason)}"}}
    end
  end

  defp serve(repos, _label, _scope, %{path: ["repodata", name]}) do
    case document_for(name) do
      {kind, :gzip} -> {:ok, "application/x-gzip", gzip(render(repos, kind))}
      {kind, :plain} -> {:ok, "application/xml", render(repos, kind)}
      :none -> {:error, :not_found}
    end
  end

  defp serve(repos, label, _scope, %{path: ["Packages" | _] = path} = ctx) do
    # Matched on the whole path rather than the filename: two
    # repositories in one namespace may both publish
    # `koco-0.1.2-1.x86_64.rpm`, and serving the wrong one would be a
    # silent substitution rather than an error.
    wanted = Enum.join(path, "/")

    repos
    |> rpms()
    |> Enum.find(&(&1.href == wanted))
    |> case do
      %{file: %PackageFile{} = file} -> {:blob, file}
      nil -> listing(repos, label, ctx)
    end
  end

  defp serve(repos, label, _scope, ctx), do: listing(repos, label, ctx)

  defp document_for("primary.xml.gz"), do: {:primary, :gzip}
  defp document_for("filelists.xml.gz"), do: {:filelists, :gzip}
  defp document_for("other.xml.gz"), do: {:other, :gzip}
  defp document_for("primary.xml"), do: {:primary, :plain}
  defp document_for("filelists.xml"), do: {:filelists, :plain}
  defp document_for("other.xml"), do: {:other, :plain}
  defp document_for(_), do: :none

  defp render(repos, kind), do: render_packages(rpms(repos), kind)

  defp render_packages(packages, :primary), do: Metadata.primary(packages)
  defp render_packages(packages, :filelists), do: Metadata.filelists(packages)
  defp render_packages(packages, :other), do: Metadata.other(packages)

  # `:zlib.gzip/1` writes a zero mtime, which is the only reason this
  # can be called twice — once to checksum into repomd.xml, once to
  # serve — and produce the same bytes both times.
  defp gzip(iodata), do: :zlib.gzip(IO.iodata_to_binary(iodata))

  # ── repomd.xml ──────────────────────────────────────────────────────

  defp repomd(repos, label) do
    packages = rpms(repos)
    revision = revision(packages)

    [
      ~s(<?xml version="1.0" encoding="UTF-8"?>\n),
      ~s(<repomd xmlns="http://linux.duke.edu/metadata/repo" ),
      ~s(xmlns:rpm="http://linux.duke.edu/metadata/rpm">\n),
      "  <!-- ",
      esc(label),
      " -->\n",
      "  <revision>",
      Integer.to_string(revision),
      "</revision>\n",
      Enum.map(@documents, &repomd_data(&1, packages, revision)),
      "</repomd>\n"
    ]
  end

  defp repomd_data(kind, packages, revision) do
    plain = IO.iodata_to_binary(render_packages(packages, kind))
    compressed = :zlib.gzip(plain)

    [
      ~s(  <data type="#{kind}">\n),
      ~s(    <checksum type="sha256">#{digest(compressed)}</checksum>\n),
      ~s(    <open-checksum type="sha256">#{digest(plain)}</open-checksum>\n),
      ~s(    <location href="repodata/#{kind}.xml.gz"/>\n),
      ~s(    <timestamp>#{revision}</timestamp>\n),
      ~s(    <size>#{byte_size(compressed)}</size>\n),
      ~s(    <open-size>#{byte_size(plain)}</open-size>\n),
      "  </data>\n"
    ]
  end

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

  # The newest publish in the repository, or zero for an empty one.
  # Never `now`: dnf caches on the revision, so a clock-derived one
  # invalidates every client's metadata on every fetch, and — worse —
  # the checksums pinned beside it would describe a document generated
  # a moment earlier.
  defp revision([]), do: 0

  defp revision(packages) do
    packages
    |> Enum.map(&unix(&1.file.inserted_at))
    |> Enum.max(fn -> 0 end)
  end

  defp unix(%DateTime{} = at), do: DateTime.to_unix(at)

  defp unix(%NaiveDateTime{} = at),
    do: at |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix()

  defp unix(_), do: 0

  # ── the packages ────────────────────────────────────────────────────

  # Every .rpm across `repos`, with the summary stored at upload and
  # the path it is served under. One query per package rather than a
  # join is an N+1, but a namespace's package list is small and this
  # keeps the substrate's API unchanged.
  defp rpms(repos) do
    Enum.flat_map(repos, fn %Repository{} = repo ->
      repo
      |> Packages.list_packages(@format)
      |> Enum.flat_map(&Packages.list_package_files/1)
      |> Enum.map(fn %PackageFile{} = file ->
        meta = (file.metadata || %{})["rpm"] || %{}
        %{file: file, meta: meta, repo_name: repo.name, href: href(repo.name, meta, file)}
      end)
    end)
    |> Enum.sort_by(& &1.href)
  end

  @doc """
  Where a package is served, relative to the repository root.

  Qualified by the source repository, which a stock rpm-md tree is not.
  It has to be: a namespace repository aggregates several, and two of
  them publishing `koco-0.1.2-1.x86_64.rpm` would otherwise be one path
  with two sets of bytes. `location href` is opaque to dnf, and
  qualifying it in *both* views means the per-repository and namespace
  indices name the same file identically — a client that switches
  between them re-downloads nothing.
  """
  def href(repo_name, meta, %PackageFile{} = file) do
    initial = meta["name"] |> to_string() |> String.first() |> Kernel.||("_")
    Path.join(["Packages", repo_name, String.downcase(initial), file.filename])
  end

  defp listing(repos, label, ctx) do
    packages = rpms(repos)

    paths =
      Enum.map(
        ~w(repomd.xml repomd.xml.asc repomd.xml.key primary.xml.gz filelists.xml.gz other.xml.gz),
        &%{path: "repodata/#{&1}", size: nil, at: nil}
      ) ++
        Enum.map(
          packages,
          &%{path: &1.href, size: &1.file.size, at: &1.file.inserted_at}
        )

    Listing.serve(paths, label, "RPM repository · dnf reads this tree; you can browse it.", ctx)
  end

  defp repo_label(%Repository{} = repo) do
    repo = GitGud.Repo.preload(repo, [:owner, :organization])
    "#{Storage.repo_handle(repo)}/#{repo.name}"
  end

  defp esc(value) do
    value |> to_string() |> String.replace("&", "&amp;") |> String.replace("<", "&lt;")
  end

  # ── uploads ─────────────────────────────────────────────────────────

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

  def store(repo, %{method: "POST", path: path} = ctx, body)
      when is_binary(body) and (path == ["upload"] or path == []) do
    with {:ok, header} <- Header.parse(body) do
      meta = Metadata.summarize(header)
      name = meta["name"]
      version = evr(meta)

      attrs = %{
        filename: Header.filename(header),
        body: body,
        content_type: content_type(name),
        # Namespaced under "rpm" so the summary can grow without
        # colliding with anything the substrate later puts here.
        metadata: %{"rpm" => meta},
        version_metadata: %{"rpm" => Map.drop(meta, ["files"])},
        uploader: uploader(ctx)
      }

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

        {: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 .rpm to /upload"}}

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

  # The version a package is filed under. `version-release`, with the
  # epoch only when there is one — which is how RPM itself prints an
  # EVR, and means the common case reads as the upstream version rather
  # than as `0:1.2.3-1`.
  defp evr(%{"epoch" => epoch} = meta) when is_integer(epoch) and epoch > 0,
    do: "#{epoch}:#{meta["version"]}-#{meta["release"]}"

  defp evr(meta), do: "#{meta["version"]}-#{meta["release"]}"

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