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