12.0 KiB · text History b868907
defmodule GitGud.Packages.Formats.Rpm.Metadata do
  @moduledoc """
  The rpm-md XML documents, and the summary of a package they are built
  from.

  `summarize/1` runs once, at upload, and reduces a parsed
  `Rpm.Header` to the fields the three index documents need. That
  summary is what gets stored on the `PackageFile`, so serving the
  index never touches the `.rpm` blobs — the same trade the apt
  repository makes by storing the control stanza.

  The rest of this module renders:

    * `primary.xml` — what dnf reads to resolve a transaction: names,
      versions, dependencies, and the subset of files that anything
      depends on by path.
    * `filelists.xml` — every file in every package. Fetched only when
      a dependency is on a path `primary.xml` filtered out.
    * `other.xml` — changelogs. Emitted empty: RPM stores them in the
      header and nothing here reads them yet, but dnf expects the
      document to exist and a repository without it looks corrupt
      rather than sparse.

  ## Why the file list is split across two documents

  It is the whole reason rpm-md has three files instead of one.
  `filelists.xml` for a full distribution is enormous, and almost every
  dependency on a path is on something under `bin/` or `/etc`. So
  createrepo puts those few in `primary.xml` and the rest in a document
  most clients never fetch. The filter here is the same one createrepo
  uses, because a client that can't find `/usr/bin/foo` in `primary`
  and doesn't know to look further would resolve differently against
  our repository than against a stock one.
  """

  import Bitwise

  alias GitGud.Packages.Formats.Rpm.Header

  # rpmsenseFlags, the comparison bits of them.
  @sense_less 0x02
  @sense_greater 0x04
  @sense_equal 0x08

  # A dependency rpm itself satisfies — `rpmlib(FileDigests)` and
  # friends. They appear in every package built this decade and mean
  # nothing to a repository, and createrepo drops them, so a client
  # comparing our index with a mirror's sees the same list.
  @sense_rpmlib 0x1000000

  # Flags that make a requirement an install-time prerequisite.
  @sense_prereq 0x6E0

  # S_IFDIR and S_IFLNK, for the `type` attribute on a file entry.
  @s_ifmt 0xF000
  @s_ifdir 0x4000

  @doc """
  Reduce a parsed header to the map stored on the `PackageFile`.

  Keys are strings because this round-trips through JSON on its way to
  and from the database, and a map that changes shape depending on
  whether it was just parsed or just loaded is a bug waiting to be
  written.
  """
  @spec summarize(Header.t()) :: map()
  def summarize(header) do
    %{
      "name" => Header.get(header, :name),
      "epoch" => Header.get(header, :epoch),
      "version" => Header.get(header, :version),
      "release" => Header.get(header, :release),
      "arch" => Header.get(header, :arch),
      "summary" => Header.get(header, :summary),
      "description" => Header.get(header, :description),
      "license" => Header.get(header, :license),
      "vendor" => Header.get(header, :vendor),
      "group" => Header.get(header, :group),
      "buildhost" => Header.get(header, :buildhost),
      "sourcerpm" => Header.get(header, :sourcerpm),
      "url" => Header.get(header, :url),
      "packager" => Header.get(header, :packager),
      "buildtime" => Header.get(header, :buildtime),
      "installed_size" => Header.get(header, :size),
      "archive_size" => Header.get(header, :archivesize),
      "header_start" => header.header_start,
      "header_end" => header.header_end,
      "provides" => deps(header, :providename, :provideflags, :provideversion),
      "requires" => header |> deps(:requirename, :requireflags, :requireversion) |> drop_rpmlib(),
      "conflicts" => deps(header, :conflictname, :conflictflags, :conflictversion),
      "obsoletes" => deps(header, :obsoletename, :obsoleteflags, :obsoleteversion),
      "files" => files(header)
    }
  end

  defp deps(header, name_tag, flags_tag, version_tag) do
    names = Header.list(header, name_tag)
    flags = Header.list(header, flags_tag)
    versions = Header.list(header, version_tag)

    names
    |> Enum.with_index()
    |> Enum.map(fn {name, index} ->
      %{
        "name" => name,
        "flags" => Enum.at(flags, index, 0),
        "version" => Enum.at(versions, index, "")
      }
    end)
    |> Enum.uniq()
  end

  defp drop_rpmlib(deps),
    do: Enum.reject(deps, &((&1["flags"] &&& @sense_rpmlib) != 0))

  defp files(header) do
    modes = Header.list(header, :filemodes)

    header
    |> Header.files()
    |> Enum.with_index()
    |> Enum.map(fn {path, index} ->
      mode = Enum.at(modes, index, 0)
      %{"path" => path, "dir" => (mode &&& @s_ifmt) == @s_ifdir}
    end)
  end

  @doc """
  Whether a file belongs in `primary.xml` as well as `filelists.xml`.

  createrepo's filter, unchanged: anything under a `bin/` directory,
  anything in `/etc`, and sendmail. Diverging from it would make our
  repository resolve differently from a stock one for the same
  packages, which is a worse failure than a large index.
  """
  @spec primary_file?(String.t()) :: boolean()
  def primary_file?(path) do
    String.contains?(path, "bin/") or String.starts_with?(path, "/etc/") or
      path == "/usr/lib/sendmail"
  end

  # ── the documents ───────────────────────────────────────────────────

  @doc """
  `primary.xml`, over `packages` — each a `%{meta:, file:, href:}`.
  """
  def primary(packages) do
    [
      ~s(<?xml version="1.0" encoding="UTF-8"?>\n),
      ~s(<metadata xmlns="http://linux.duke.edu/metadata/common" ),
      ~s(xmlns:rpm="http://linux.duke.edu/metadata/rpm" packages="#{length(packages)}">\n),
      Enum.map(packages, &primary_package/1),
      "</metadata>\n"
    ]
  end

  defp primary_package(%{meta: meta, file: file, href: href}) do
    [
      ~s(<package type="rpm">\n),
      element("name", meta["name"]),
      element("arch", meta["arch"]),
      version_element(meta),
      ~s(  <checksum type="sha256" pkgid="YES">#{file.sha256}</checksum>\n),
      element("summary", meta["summary"]),
      element("description", meta["description"]),
      element("packager", meta["packager"]),
      element("url", meta["url"]),
      ~s(  <time file="#{file_time(file)}" build="#{meta["buildtime"] || 0}"/>\n),
      ~s(  <size package="#{file.size}" installed="#{meta["installed_size"] || 0}" ),
      ~s(archive="#{meta["archive_size"] || 0}"/>\n),
      ~s(  <location href="#{esc(href)}"/>\n),
      "  <format>\n",
      rpm_element("license", meta["license"]),
      rpm_element("vendor", meta["vendor"]),
      rpm_element("group", meta["group"]),
      rpm_element("buildhost", meta["buildhost"]),
      rpm_element("sourcerpm", meta["sourcerpm"]),
      ~s(    <rpm:header-range start="#{meta["header_start"]}" end="#{meta["header_end"]}"/>\n),
      entries("provides", meta["provides"]),
      entries("requires", meta["requires"]),
      entries("conflicts", meta["conflicts"]),
      entries("obsoletes", meta["obsoletes"]),
      meta["files"]
      |> List.wrap()
      |> Enum.filter(&primary_file?(&1["path"]))
      |> Enum.map(&file_element(&1, "    ")),
      "  </format>\n</package>\n"
    ]
  end

  @doc "`filelists.xml`, over the same package list."
  def filelists(packages) do
    [
      ~s(<?xml version="1.0" encoding="UTF-8"?>\n),
      ~s(<filelists xmlns="http://linux.duke.edu/metadata/filelists" ),
      ~s(packages="#{length(packages)}">\n),
      Enum.map(packages, fn %{meta: meta, file: file} ->
        [
          package_open("package", meta, file),
          version_element(meta),
          meta["files"] |> List.wrap() |> Enum.map(&file_element(&1, "  ")),
          "</package>\n"
        ]
      end),
      "</filelists>\n"
    ]
  end

  @doc """
  `other.xml` — the package identities with no changelog under them.

  An empty `<package>` is what createrepo emits for a package whose
  changelog it was told to skip, so this is a sparse document rather
  than a malformed one.
  """
  def other(packages) do
    [
      ~s(<?xml version="1.0" encoding="UTF-8"?>\n),
      ~s(<otherdata xmlns="http://linux.duke.edu/metadata/other" ),
      ~s(packages="#{length(packages)}">\n),
      Enum.map(packages, fn %{meta: meta, file: file} ->
        [package_open("package", meta, file), version_element(meta), "</package>\n"]
      end),
      "</otherdata>\n"
    ]
  end

  # ── XML bits ────────────────────────────────────────────────────────

  defp package_open(tag, meta, file) do
    ~s(<#{tag} pkgid="#{file.sha256}" name="#{esc(meta["name"])}" ) <>
      ~s(arch="#{esc(meta["arch"])}">\n)
  end

  # Epoch is "0" rather than absent when a package doesn't declare one:
  # RPM's comparison treats a missing epoch as zero, and leaving the
  # attribute out makes some clients treat it as unset instead.
  defp version_element(meta) do
    ~s(  <version epoch="#{meta["epoch"] || 0}" ver="#{esc(meta["version"])}" ) <>
      ~s(rel="#{esc(meta["release"])}"/>\n)
  end

  defp file_element(%{"path" => path, "dir" => true}, indent),
    do: [indent, ~s(<file type="dir">), esc(path), "</file>\n"]

  defp file_element(%{"path" => path}, indent),
    do: [indent, "<file>", esc(path), "</file>\n"]

  defp entries(_kind, nil), do: []
  defp entries(_kind, []), do: []

  defp entries(kind, deps) do
    [
      "    <rpm:#{kind}>\n",
      Enum.map(deps, &entry/1),
      "    </rpm:#{kind}>\n"
    ]
  end

  defp entry(%{"name" => name, "flags" => flags, "version" => version}) do
    [
      ~s(      <rpm:entry name="),
      esc(name),
      "\"",
      flag_attributes(flags, version),
      pre_attribute(flags),
      "/>\n"
    ]
  end

  # A dependency with no comparison bits is unversioned, and carrying a
  # version on it anyway is how `Requires: foo` turns into
  # `Requires: foo = 1.0` in a client's view.
  defp flag_attributes(flags, version) do
    case comparison(flags) do
      nil ->
        []

      operator ->
        {epoch, ver, rel} = split_evr(version)

        [
          ~s( flags="#{operator}" epoch="#{epoch}" ver="#{esc(ver)}"),
          if(rel in [nil, ""], do: [], else: ~s( rel="#{esc(rel)}"))
        ]
    end
  end

  defp comparison(flags) do
    case flags &&& (@sense_less ||| @sense_greater ||| @sense_equal) do
      @sense_equal -> "EQ"
      @sense_less -> "LT"
      @sense_greater -> "GT"
      0x0A -> "LE"
      0x0C -> "GE"
      _ -> nil
    end
  end

  defp pre_attribute(flags) when (flags &&& @sense_prereq) != 0, do: ~s( pre="1")
  defp pre_attribute(_flags), do: []

  # RPM stores a dependency's version as the `[epoch:]version[-release]`
  # string a spec file wrote, and rpm-md wants the three parts apart.
  defp split_evr(version) do
    {epoch, rest} =
      case String.split(version || "", ":", parts: 2) do
        [epoch, rest] -> {epoch, rest}
        [rest] -> {"0", rest}
      end

    case String.split(rest, "-", parts: 2) do
      [ver, rel] -> {epoch, ver, rel}
      [ver] -> {epoch, ver, nil}
    end
  end

  defp element(_name, value) when value in [nil, ""], do: []
  defp element(name, value), do: ["  <", name, ">", esc(value), "</", name, ">\n"]

  defp rpm_element(_name, value) when value in [nil, ""], do: []

  defp rpm_element(name, value),
    do: ["    <rpm:", name, ">", esc(value), "</rpm:", name, ">\n"]

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

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

  defp file_time(_), do: 0

  defp esc(value) do
    value
    |> to_string()
    |> String.replace("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
    # RPM descriptions are free text and occasionally carry control
    # bytes an XML 1.0 parser rejects outright. Dropping them is the
    # only option that still produces a parseable document.
    |> String.replace(~r/[\x00-\x08\x0b\x0c\x0e-\x1f]/, "")
  end
end