7.3 KiB · text History b868907
defmodule GitGud.Packages.Formats.Deb.Control do
  @moduledoc """
  Reads the control stanza out of a `.deb`.

  A `.deb` is an `ar` archive of three members, in order:

      debian-binary        the format version, "2.0\\n"
      control.tar.{gz,xz,zst}
      data.tar.{gz,xz,zst}

  `parse/1` reads only the control member. Inside it is a `control`
  file holding one RFC 822-ish stanza: `Package`, `Version`,
  `Architecture`, `Depends`, `Description` and friends. `data/1` hands
  back the data member, still compressed, for the one thing the index
  wants from the payload — the desktop entry's icon.

  `ar` is parsed here rather than shelled out to, because `ar` is
  sixty bytes of fixed-width ASCII per member and shelling out would
  put `binutils` in the runtime image. xz and zstd go through the NIF
  — `:erl_tar` only knows gzip.
  """

  alias GitGud.Packages.Apt.Native

  @magic "!<arch>\n"

  @type stanza :: %{String.t() => String.t()}

  @doc """
  Parse a `.deb`. Returns `{:ok, stanza}` with the control fields
  keyed by their canonical names, or `{:error, {status, message}}`
  shaped for the registry controller.
  """
  @spec parse(binary()) :: {:ok, stanza()} | {:error, {integer(), String.t()}}
  def parse(<<@magic, rest::binary>>) do
    with {:ok, members} <- ar_members(rest, %{}),
         {:ok, name, blob} <- control_member(members),
         {:ok, tar} <- decompress(name, blob),
         {:ok, control} <- control_file(tar) do
      parse_stanza(control)
    end
  end

  def parse(_), do: {:error, {400, "not an ar archive — a .deb starts with !<arch>"}}

  @doc """
  The `data.tar` member of a `.deb`, as `{:ok, compression, blob}` —
  `compression` being `:gzip`, `:xz`, `:zstd` or `:none` — or `:error`.
  Left compressed: whether to pay for decompressing it is the caller's
  call.
  """
  @spec data(binary()) :: {:ok, atom(), binary()} | :error
  def data(<<@magic, rest::binary>>) do
    with {:ok, members} <- ar_members(rest, %{}),
         {name, blob} <-
           Enum.find(members, fn {name, _} -> String.starts_with?(name, "data.tar") end),
         kind when kind != nil <- compression(Path.extname(name)) do
      {:ok, kind, blob}
    else
      _ -> :error
    end
  end

  def data(_), do: :error

  defp compression(".gz"), do: :gzip
  defp compression(".xz"), do: :xz
  defp compression(".zst"), do: :zstd
  defp compression(".tar"), do: :none
  defp compression(_), do: nil

  # ── ar ──────────────────────────────────────────────────────────────

  # Each member: a 60-byte header (name[16] mtime[12] uid[6] gid[6]
  # mode[8] size[10] magic[2]) then `size` bytes, padded to an even
  # offset. Sizes are decimal ASCII, right-padded with spaces.
  defp ar_members(<<>>, acc), do: {:ok, acc}
  defp ar_members(<<"\n", rest::binary>>, acc), do: ar_members(rest, acc)

  defp ar_members(
         <<name::binary-size(16), _mtime::binary-size(12), _uid::binary-size(6),
           _gid::binary-size(6), _mode::binary-size(8), size::binary-size(10), 0x60, 0x0A,
           rest::binary>>,
         acc
       ) do
    case Integer.parse(String.trim(size)) do
      {size, _} when size >= 0 and byte_size(rest) >= size ->
        <<data::binary-size(^size), tail::binary>> = rest
        # Members are padded to an even boundary with a newline.
        tail = if rem(size, 2) == 1, do: binary_part(tail, 1, byte_size(tail) - 1), else: tail

        ar_members(tail, Map.put(acc, ar_name(name), data))

      _ ->
        {:error, {400, "the .deb's ar member table is truncated"}}
    end
  end

  defp ar_members(_, acc) when map_size(acc) > 0, do: {:ok, acc}
  defp ar_members(_, _), do: {:error, {400, "the .deb's ar member table is unreadable"}}

  # GNU ar terminates a name with `/`; BSD pads with spaces.
  defp ar_name(raw), do: raw |> String.trim() |> String.trim_trailing("/")

  defp control_member(members) do
    members
    |> Enum.find(fn {name, _} -> String.starts_with?(name, "control.tar") end)
    |> case do
      {name, blob} -> {:ok, name, blob}
      nil -> {:error, {400, "the .deb has no control.tar member"}}
    end
  end

  # ── decompression ───────────────────────────────────────────────────

  defp decompress(name, blob) do
    case Path.extname(name) do
      ".gz" -> gunzip(blob)
      ".xz" -> nif(Native.xz_decompress(blob), "xz")
      ".zst" -> nif(Native.zstd_decompress(blob), "zstd")
      "" -> {:ok, blob}
      ".tar" -> {:ok, blob}
      other -> {:error, {400, "unsupported control.tar compression `#{other}`"}}
    end
  end

  defp gunzip(blob) do
    {:ok, :zlib.gunzip(blob)}
  rescue
    _ -> {:error, {400, "the .deb's control.tar.gz is not readable"}}
  end

  defp nif({:ok, bytes}, _label), do: {:ok, bytes}
  defp nif({:error, reason}, label), do: {:error, {400, "#{label}: #{reason}"}}

  defp control_file(tar) do
    case :erl_tar.extract({:binary, tar}, [:memory]) do
      {:ok, members} ->
        members
        |> Enum.find(fn {name, _} -> Path.basename(to_string(name)) == "control" end)
        |> case do
          {_name, content} -> {:ok, content}
          nil -> {:error, {400, "the .deb's control.tar has no control file"}}
        end

      {:error, reason} ->
        {:error, {400, "the .deb's control.tar is not readable: #{inspect(reason)}"}}
    end
  end

  # ── the stanza ──────────────────────────────────────────────────────

  @doc """
  Parse one Debian control stanza.

  Field bodies continue onto any following line that starts with
  whitespace — which is how `Description` carries its long form, and
  the reason this can't be a `String.split/2` on newlines.
  """
  @spec parse_stanza(binary()) :: {:ok, stanza()} | {:error, {integer(), String.t()}}
  def parse_stanza(text) when is_binary(text) do
    fields =
      text
      |> String.split("\n")
      |> Enum.reduce({[], nil}, fn line, {fields, current} ->
        cond do
          # A continuation line: leading whitespace, and not the blank
          # line that would end the stanza.
          current != nil and String.match?(line, ~r/\A[ \t]\S/) ->
            {[{current, line} | fields], current}

          String.match?(line, ~r/\A[ \t]*\z/) ->
            {fields, current}

          true ->
            case String.split(line, ":", parts: 2) do
              [name, value] -> {[{name, String.trim(value)} | fields], name}
              _ -> {fields, current}
            end
        end
      end)
      |> elem(0)
      |> Enum.reverse()
      |> Enum.reduce(%{}, fn {name, value}, acc ->
        Map.update(acc, name, value, fn existing -> existing <> "\n" <> value end)
      end)

    case fields do
      %{"Package" => _, "Version" => _} -> {:ok, fields}
      _ -> {:error, {400, "the control stanza has no Package and Version"}}
    end
  end

  @doc """
  The `Description-md5` apt has required since 2.7.7: the MD5 of the
  description, newline-terminated.
  """
  @spec description_md5(String.t() | nil) :: String.t() | nil
  def description_md5(nil), do: nil

  def description_md5(description) do
    body = String.trim_trailing(description, "\n") <> "\n"
    Base.encode16(:crypto.hash(:md5, body), case: :lower)
  end
end