11.3 KiB · text History b868907
defmodule GitGud.Packages.Formats.Rpm.Header do
  @moduledoc """
  Reads the metadata headers out of an `.rpm`.

  An RPM file is four things back to back:

      lead                96 bytes, a fixed struct kept only for `file`
      signature header    digests and signatures over what follows
      (padding)           to an 8-byte boundary
      main header         everything the repository index needs
      payload             a compressed cpio archive

  Both headers use the same structure: an 8-byte magic and version, a
  count of index entries, the byte size of the data store, then the
  entries — `{tag, type, offset, count}`, four big-endian words each —
  followed by the store the offsets point into.

  That is the whole format. It is parsed here rather than shelled out
  to `rpm` for the same reason `Deb.Control` parses `ar` itself: the
  runtime image has no rpm tooling in it, and adding some would be a
  deployment change wearing a dependency's clothes.

  ## What is deliberately not read

  The payload, for the index. `primary.xml` and `filelists.xml` are built entirely
  from header tags — even the file list, which RPM stores as
  `DIRNAMES` / `DIRINDEXES` / `BASENAMES` in the header precisely so a
  repository can be indexed without decompressing anything. So a 200 MB
  package is parsed by reading its first few hundred kilobytes.

  The signature header, beyond skipping it. Verifying a package's own
  OpenPGP signature is a client-side concern — `rpm --checksig` against
  a key the administrator imported — and the repository's job is to
  sign `repomd.xml`, which it does.
  """

  @lead_magic <<0xED, 0xAB, 0xEE, 0xDB>>
  @header_magic <<0x8E, 0xAD, 0xE8, 0x01>>

  @lead_size 96

  # Tag numbers, from rpm's `rpmtag.h`. Only the ones an index needs.
  # The signature header numbers its tags in the same low range as the
  # main header and means entirely different things by them — 1007 is
  # the payload size here and the build host there. So the two get
  # separate tables; one shared table reads a size as a hostname.
  @signature_tags %{
    271 => :longarchivesize,
    1007 => :archivesize
  }

  @tags %{
    1000 => :name,
    1001 => :version,
    1002 => :release,
    1003 => :epoch,
    1004 => :summary,
    1005 => :description,
    1006 => :buildtime,
    1007 => :buildhost,
    1009 => :size,
    1011 => :vendor,
    1014 => :license,
    1015 => :packager,
    1016 => :group,
    1020 => :url,
    1021 => :os,
    1022 => :arch,
    1030 => :filemodes,
    1044 => :sourcerpm,
    1046 => :archivesize,
    1047 => :providename,
    1048 => :requireflags,
    1049 => :requirename,
    1050 => :requireversion,
    1053 => :conflictflags,
    1054 => :conflictname,
    1055 => :conflictversion,
    1090 => :obsoletename,
    1112 => :provideflags,
    1113 => :provideversion,
    1114 => :obsoleteflags,
    1115 => :obsoleteversion,
    1116 => :dirindexes,
    1117 => :basenames,
    1118 => :dirnames,
    1125 => :payloadcompressor
  }

  @type t :: %{
          tags: %{atom() => term()},
          header_start: non_neg_integer(),
          header_end: non_neg_integer()
        }

  @doc """
  Parse `body`. Returns `{:ok, header}` or `{:error, {status, message}}`
  shaped for the registry controller.

  `header_start` and `header_end` bracket the main header in the file.
  They go into `primary.xml` as `<rpm:header-range/>`, which is what
  lets dnf fetch a package's metadata with a range request instead of
  downloading it — the one piece of the index that cannot be derived
  from the tags themselves.
  """
  @spec parse(binary()) :: {:ok, t()} | {:error, {integer(), String.t()}}
  def parse(<<@lead_magic, _lead::binary-size(@lead_size - 4), rest::binary>> = body) do
    with {:ok, signature, after_signature} <- read_header(rest, @lead_size, @signature_tags),
         # The main header starts on an 8-byte boundary. Only the
         # signature header is padded this way; the offset is from the
         # start of the file, not from the header.
         start = align8(after_signature),
         {:ok, tags, header_end} <- read_header(binary_part_from(body, start), start, @tags) do
      # `ARCHIVESIZE` is in the main header only on packages built
      # before rpm moved it; everything current carries the 64-bit
      # `LONGARCHIVESIZE` in the signature header instead. Both spell
      # the same number, so they collapse into one tag here rather than
      # making every caller check two.
      tags =
        case {tags[:archivesize], signature[:longarchivesize] || signature[:archivesize]} do
          {nil, nil} -> tags
          {nil, size} -> Map.put(tags, :archivesize, size)
          _ -> tags
        end

      {:ok, %{tags: tags, header_start: start, header_end: header_end}}
    end
  end

  def parse(_),
    do: {:error, {422, "not an RPM — the file does not start with the RPM lead magic"}}

  @doc """
  A tag's value, or `nil`. Scalars come back as an integer or a string,
  arrays as a list.
  """
  @spec get(t(), atom()) :: term()
  def get(%{tags: tags}, tag), do: Map.get(tags, tag)

  @doc """
  A tag's value as a list, whether RPM stored it as one or not. Several
  tags are singular in some packages and arrays in others, and every
  caller here wants the list.
  """
  @spec list(t(), atom()) :: [term()]
  def list(header, tag) do
    case get(header, tag) do
      nil -> []
      value when is_list(value) -> value
      value -> [value]
    end
  end

  @doc """
  The package's full file list, reassembled from the three tags RPM
  splits it across.

  `BASENAMES` holds every filename, `DIRNAMES` the distinct directories
  and `DIRINDEXES` the index into `DIRNAMES` for each basename. The
  split is what keeps the header small — a package with a thousand
  files under `/usr/share/doc/foo/` stores that prefix once.
  """
  @spec files(t()) :: [String.t()]
  def files(header) do
    dirs = list(header, :dirnames)
    indexes = list(header, :dirindexes)
    bases = list(header, :basenames)

    bases
    |> Enum.zip(indexes)
    |> Enum.map(fn {base, index} -> "#{Enum.at(dirs, index, "")}#{base}" end)
  end

  @doc """
  The package's `name-version-release.arch.rpm` filename — what
  `rpmbuild` writes and what every mirror serves it as.
  """
  @spec filename(t()) :: String.t()
  def filename(header) do
    "#{get(header, :name)}-#{get(header, :version)}-#{get(header, :release)}.#{get(header, :arch)}.rpm"
  end

  @doc """
  The payload — everything after the main header — as
  `{:ok, compression, blob}`, or `:error` for a compressor nothing here
  can undo (`lzma`, `bzip2`). Left compressed, like `Deb.Control.data/1`.

  Only the desktop entry's icon wants it; see the note above about why
  the index never does.
  """
  @spec payload(binary(), t()) :: {:ok, atom(), binary()} | :error
  def payload(body, %{header_end: at} = header) when byte_size(body) > at do
    blob = binary_part(body, at, byte_size(body) - at)

    # rpm's default when the tag is absent, which only very old
    # packages are.
    case get(header, :payloadcompressor) || "gzip" do
      "gzip" -> {:ok, :gzip, blob}
      "xz" -> {:ok, :xz, blob}
      "zstd" -> {:ok, :zstd, blob}
      _ -> :error
    end
  end

  def payload(_body, _header), do: :error

  # ── the header structure ────────────────────────────────────────────

  # Returns `{:ok, tags, offset_of_end_in_file}`. `base` is where this
  # header starts in the file, which is the only reason it is threaded
  # through: the caller needs file offsets, not header-relative ones.
  defp read_header(
         <<@header_magic, _reserved::binary-size(4), count::32, store_size::32, rest::binary>>,
         base,
         tag_map
       ) do
    index_size = count * 16

    case rest do
      <<index::binary-size(^index_size), store::binary-size(^store_size), _::binary>> ->
        {:ok, entries(index, store, tag_map), base + 16 + index_size + store_size}

      _ ->
        {:error, {422, "RPM header is truncated"}}
    end
  end

  defp read_header(_, _, _), do: {:error, {422, "RPM header has the wrong magic"}}

  defp entries(index, store, tag_map) do
    for <<tag::32, type::32, offset::32, count::32 <- index>>,
        name = Map.get(tag_map, tag),
        name != nil,
        into: %{} do
      {name, value(type, offset, count, store)}
    end
  end

  # ── the data store ──────────────────────────────────────────────────

  # Types, from `rpmTagType_e`. CHAR and INT8 are byte-wide and appear
  # only in tags this module ignores, but decoding them costs a clause
  # and misreading one as a string would be silent.
  defp value(1, offset, count, store), do: ints(store, offset, count, 1)
  defp value(2, offset, count, store), do: ints(store, offset, count, 1)
  defp value(3, offset, count, store), do: ints(store, offset, count, 2)
  defp value(4, offset, count, store), do: ints(store, offset, count, 4)
  defp value(5, offset, count, store), do: ints(store, offset, count, 8)

  # A STRING is always count 1; the count field lies for some producers,
  # so it is ignored rather than trusted.
  defp value(6, offset, _count, store), do: cstring(store, offset)

  defp value(7, offset, count, store) do
    case store do
      <<_::binary-size(^offset), bytes::binary-size(^count), _::binary>> -> bytes
      _ -> nil
    end
  end

  defp value(8, offset, count, store), do: cstrings(store, offset, count)

  # An I18NSTRING is an array with one entry per locale in the header's
  # `HEADERI18NTABLE`, the first of which is always C. Taking the first
  # is what rpm does with no locale set, and a repository index has no
  # locale to set.
  defp value(9, offset, _count, store), do: cstring(store, offset)

  defp value(_type, _offset, _count, _store), do: nil

  defp ints(store, offset, 1, width), do: List.first(ints(store, offset, 1, width, []))
  defp ints(store, offset, count, width), do: ints(store, offset, count, width, [])

  defp ints(_store, _offset, 0, _width, acc), do: Enum.reverse(acc)

  defp ints(store, offset, count, width, acc) do
    bits = width * 8

    case store do
      <<_::binary-size(^offset), value::size(^bits), _::binary>> ->
        ints(store, offset + width, count - 1, width, [value | acc])

      _ ->
        Enum.reverse(acc)
    end
  end

  defp cstring(store, offset) do
    case store do
      <<_::binary-size(^offset), rest::binary>> ->
        case :binary.match(rest, <<0>>) do
          {at, _} -> binary_part(rest, 0, at)
          :nomatch -> rest
        end

      _ ->
        nil
    end
  end

  defp cstrings(store, offset, count), do: cstrings(store, offset, count, [])

  defp cstrings(_store, _offset, 0, acc), do: Enum.reverse(acc)

  defp cstrings(store, offset, count, acc) do
    case cstring(store, offset) do
      nil -> Enum.reverse(acc)
      string -> cstrings(store, offset + byte_size(string) + 1, count - 1, [string | acc])
    end
  end

  # ── odds and ends ───────────────────────────────────────────────────

  defp align8(offset), do: offset + rem(8 - rem(offset, 8), 8)

  defp binary_part_from(body, offset) when byte_size(body) > offset,
    do: binary_part(body, offset, byte_size(body) - offset)

  defp binary_part_from(_body, _offset), do: <<>>
end