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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
5.8 KiB · text
History
b868907