5.8 KiB · text History b868907
defmodule GitGud.Packages.Formats.Hex.Tarball do
  @moduledoc """
  Reads the metadata out of a published Hex tarball.

  The format is an uncompressed `.tar` holding four members:

      VERSION           the tarball format version ("3")
      CHECKSUM          the inner checksum, hex, uppercase
      metadata.config   Erlang terms — name, version, requirements, …
      contents.tar.gz   the package itself

  Only the first three are read. The inner checksum is taken from
  `CHECKSUM` rather than recomputed: that file is what it is for, and
  recomputing means reimplementing the exact concatenation Hex hashes,
  which is a thing to get subtly wrong rather than a thing to verify.
  The outer checksum is just SHA-256 of the whole tarball, which the
  substrate already computes.

  `metadata.config` is Erlang term syntax. It is parsed with the
  scanner and `:erl_parse.parse_term/1` — never `:erl_eval` — so an
  uploaded file is data and only data.
  """

  @type meta :: %{
          name: String.t(),
          version: String.t(),
          app: String.t() | nil,
          description: String.t() | nil,
          inner_checksum: String.t(),
          dependencies: [map()]
        }

  @doc """
  Parse a `.tar`. Returns `{:ok, meta}` or `{:error, {status, message}}`
  shaped for the registry controller.
  """
  @spec parse(binary()) :: {:ok, meta()} | {:error, {integer(), String.t()}}
  def parse(tarball) when is_binary(tarball) do
    with {:ok, members} <- extract(tarball),
         {:ok, config} <- member(members, "metadata.config"),
         {:ok, checksum} <- member(members, "CHECKSUM"),
         {:ok, terms} <- consult(config),
         {:ok, name} <- fetch(terms, "name"),
         {:ok, version} <- fetch(terms, "version") do
      {:ok,
       %{
         name: name,
         version: version,
         app: optional(terms, "app"),
         description: optional(terms, "description"),
         inner_checksum: checksum |> String.trim() |> String.downcase(),
         dependencies: requirements(terms)
       }}
    end
  end

  defp extract(tarball) do
    case :erl_tar.extract({:binary, tarball}, [:memory]) do
      {:ok, members} ->
        {:ok, Map.new(members, fn {name, content} -> {to_string(name), content} end)}

      {:error, reason} ->
        {:error, {400, "not a readable tar archive: #{inspect(reason)}"}}
    end
  end

  defp member(members, name) do
    case Map.fetch(members, name) do
      {:ok, content} -> {:ok, content}
      :error -> {:error, {400, "the tarball has no #{name} member"}}
    end
  end

  # ── Erlang term config ──────────────────────────────────────────────

  # `:file.consult/1` over a binary, without the temp file: scan the
  # whole thing once, cut it at each `.`, and parse each form as a
  # term. `:erl_parse.parse_term/1` cannot evaluate anything, which is
  # the point — this input arrived over HTTP.
  defp consult(binary) do
    case :erl_scan.string(String.to_charlist(binary)) do
      {:ok, tokens, _} ->
        tokens
        |> forms()
        |> Enum.reduce_while({:ok, []}, fn form, {:ok, acc} ->
          case :erl_parse.parse_term(form) do
            {:ok, term} -> {:cont, {:ok, [term | acc]}}
            {:error, _} -> {:halt, {:error, {400, "metadata.config is not readable"}}}
          end
        end)
        |> case do
          {:ok, terms} -> {:ok, Enum.reverse(terms)}
          err -> err
        end

      {:error, _, _} ->
        {:error, {400, "metadata.config is not readable"}}
    end
  end

  defp forms(tokens) do
    tokens
    |> Enum.chunk_while(
      [],
      fn
        {:dot, _} = dot, acc -> {:cont, Enum.reverse([dot | acc]), []}
        token, acc -> {:cont, [token | acc]}
      end,
      fn
        [] -> {:cont, []}
        acc -> {:cont, Enum.reverse(acc), []}
      end
    )
    |> Enum.reject(&(&1 == []))
  end

  defp fetch(terms, key) do
    case optional(terms, key) do
      nil -> {:error, {400, "metadata.config has no #{key}"}}
      value -> {:ok, value}
    end
  end

  defp optional(terms, key) do
    Enum.find_value(terms, fn
      {k, value} -> if to_string(k) == key, do: to_string(value)
      _ -> nil
    end)
  end

  # Requirements are a list of proplists in current Hex, and a proplist
  # keyed by package name in older tarballs. Both still turn up in a
  # `deps.get` cache, so both are read.
  defp requirements(terms) do
    case Enum.find_value(terms, fn
           {k, value} -> if to_string(k) == "requirements", do: value
           _ -> nil
         end) do
      nil -> []
      list when is_list(list) -> Enum.map(list, &requirement/1)
      _ -> []
    end
  end

  # The legacy shape keys each proplist by the package name, and the
  # inner proplist has no "name" of its own — so the key is the only
  # place the dependency's name appears.
  defp requirement({name, props}) when is_list(props) do
    case requirement(props) do
      %{"package" => ""} = parsed -> %{parsed | "package" => to_string(name)}
      parsed -> parsed
    end
  end

  defp requirement(props) when is_list(props) do
    fields =
      Enum.reduce(props, %{}, fn
        {k, v}, acc -> Map.put(acc, to_string(k), normalize(v))
        _, acc -> acc
      end)

    %{
      "package" => fields["name"] || fields["package"] || "",
      "requirement" => fields["requirement"] || "",
      "optional" => fields["optional"] == true,
      "app" => fields["app"],
      "repository" => fields["repository"]
    }
  end

  defp requirement(_), do: %{"package" => "", "requirement" => "", "optional" => false}

  defp normalize(value) when is_binary(value), do: value
  defp normalize(value) when is_boolean(value), do: value
  defp normalize(value) when is_atom(value), do: to_string(value)
  defp normalize(value) when is_list(value), do: to_string(value)
  defp normalize(value), do: value
end