12.6 KiB · text History 6bfb936
defmodule GitGud.Packages.Formats.Deb do
  @moduledoc """
  A Debian repository, per git repository.

      curl -fsSL https://<host>/pkg/deb/<owner>/<repo>/key.asc \\
        | sudo tee /etc/apt/keyrings/<repo>.asc > /dev/null
      echo "deb [signed-by=/etc/apt/keyrings/<repo>.asc] \\
        https://<host>/pkg/deb/<owner>/<repo> stable main" \\
        | sudo tee /etc/apt/sources.list.d/<repo>.list

  There is no daemon to embed here. An apt repository is static files
  over plain HTTP — which is why this is last rather than hard: the
  only genuinely new thing is the OpenPGP signature, and that lives in
  `GitGud.Packages.Apt.Native`.

  Paths below the repository root:

      /key.asc                                     the signing key
      /dists/<dist>/InRelease                      clearsigned, preferred
      /dists/<dist>/Release                        the same, unsigned
      /dists/<dist>/Release.gpg                    detached, for older apt
      /dists/<dist>/<comp>/binary-<arch>/Packages
      /dists/<dist>/<comp>/binary-<arch>/Packages.xz
      /pool/<comp>/<p>/<pkg>/<file>.deb
      /upload?dist=&component=                     POST a .deb

  ## What the spec actually requires

    * `Release` must carry Suite and/or Codename, Architectures,
      Components, Date, and SHA256. Clients are required to reject
      MD5Sum and SHA1 as security, so SHA256 is the floor and nothing
      weaker is offered for that purpose.
    * Checksums and sizes are listed for the *uncompressed* index too,
      not only the compressed one.
    * A `Packages` stanza needs Package, Filename, Size and a checksum
      — and `Description-md5`, which apt has required since 2.7.7.
    * Servers should offer xz; clients are required to support it.

  ## arch:all

  Packages built `Architecture: all` are served twice: in
  `binary-all/Packages` and folded into every concrete architecture's
  index. Debian does both, and an apt that only looks at
  `binary-amd64` still finds them.
  """

  @behaviour GitGud.Packages.Format

  alias GitGud.Packages
  alias GitGud.Packages.Apt.Native
  alias GitGud.Packages.Formats.Deb.Control
  alias GitGud.Packages.PackageFile
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage

  @format "deb"

  @default_dist "stable"
  @default_component "main"

  # `Architecture: all` is not an architecture, it is the absence of
  # one, and it appears in every architecture's index.
  @arch_all "all"

  @impl true
  def content_type(_filename), do: "application/vnd.debian.binary-package"

  # ── reads ───────────────────────────────────────────────────────────

  @impl true
  def index(repo, ctx)

  def index(repo, %{path: ["key.asc"]}) do
    with {:ok, %{public_pem: armored}} <- Packages.signing_key(repo, @format) do
      {:ok, "application/pgp-keys", armored}
    end
  end

  def index(repo, %{path: ["dists", dist, "InRelease"]}) do
    with {:ok, %{private_pem: secret}} <- Packages.signing_key(repo, @format),
         {:ok, signed} <- Native.sign_clearsign(secret, release_document(repo, dist)) do
      {:ok, "text/plain; charset=utf-8", signed}
    else
      {:error, reason} -> {:error, {500, "could not sign the Release: #{inspect(reason)}"}}
    end
  end

  def index(repo, %{path: ["dists", dist, "Release"]}),
    do: {:ok, "text/plain; charset=utf-8", release_document(repo, dist)}

  def index(repo, %{path: ["dists", dist, "Release.gpg"]}) do
    with {:ok, %{private_pem: secret}} <- Packages.signing_key(repo, @format),
         {:ok, signature} <- Native.sign_detached(secret, release_document(repo, dist)) do
      {:ok, "application/pgp-signature", signature}
    else
      {:error, reason} -> {:error, {500, "could not sign the Release: #{inspect(reason)}"}}
    end
  end

  def index(repo, %{path: ["dists", dist, component, "binary-" <> arch, "Packages"]}),
    do: {:ok, "text/plain; charset=utf-8", packages_document(repo, dist, component, arch)}

  def index(repo, %{path: ["dists", dist, component, "binary-" <> arch, "Packages.xz"]}) do
    case Native.xz_compress(packages_document(repo, dist, component, arch)) do
      {:ok, compressed} -> {:ok, "application/x-xz", compressed}
      {:error, reason} -> {:error, {500, "could not compress the index: #{reason}"}}
    end
  end

  def index(repo, %{path: ["pool" | _] = path}) do
    filename = List.last(path)

    case find_deb(repo, filename) do
      %PackageFile{} = file -> {:blob, file}
      nil -> {:error, :not_found}
    end
  end

  def index(_repo, _ctx), do: {:error, :not_found}

  defp find_deb(repo, filename) do
    repo
    |> Packages.list_packages(@format)
    |> Enum.find_value(fn package -> Packages.get_package_file(package, filename) end)
  end

  # ── the indices ─────────────────────────────────────────────────────

  # Every .deb in the repository, flattened with the fields the
  # indices key on. One query per package rather than one join is an
  # N+1, but a repository's package list is small and this keeps the
  # substrate's API unchanged.
  defp debs(repo) do
    repo
    |> Packages.list_packages(@format)
    |> Enum.flat_map(&Packages.list_package_files/1)
    |> Enum.map(fn %PackageFile{} = file ->
      metadata = file.metadata || %{}

      %{
        file: file,
        dist: metadata["dist"] || @default_dist,
        component: metadata["component"] || @default_component,
        architecture: metadata["architecture"] || @arch_all,
        control: metadata["control"] || %{}
      }
    end)
  end

  defp packages_document(repo, dist, component, arch) do
    repo
    |> debs()
    |> Enum.filter(fn deb ->
      deb.dist == dist and deb.component == component and
        (deb.architecture == arch or deb.architecture == @arch_all)
    end)
    |> Enum.sort_by(&{&1.control["Package"], &1.control["Version"]})
    |> Enum.map_join("\n", &packages_stanza/1)
  end

  # Field order follows dpkg-scanpackages, which is what every
  # repository in the wild looks like. `MD5sum` here and `MD5Sum` in
  # Release is not a typo — the two files really do differ, and apt's
  # parser is case-insensitive either way.
  defp packages_stanza(deb) do
    control = deb.control
    description = control["Description"]

    [
      {"Package", control["Package"]},
      {"Version", control["Version"]},
      {"Architecture", deb.architecture},
      {"Maintainer", control["Maintainer"]},
      {"Installed-Size", control["Installed-Size"]},
      {"Depends", control["Depends"]},
      {"Pre-Depends", control["Pre-Depends"]},
      {"Recommends", control["Recommends"]},
      {"Suggests", control["Suggests"]},
      {"Conflicts", control["Conflicts"]},
      {"Breaks", control["Breaks"]},
      {"Replaces", control["Replaces"]},
      {"Provides", control["Provides"]},
      {"Section", control["Section"]},
      {"Priority", control["Priority"]},
      {"Homepage", control["Homepage"]},
      {"Filename", pool_path(deb)},
      {"Size", deb.file.size},
      {"MD5sum", deb.file.md5},
      {"SHA256", deb.file.sha256},
      {"Description", description},
      {"Description-md5", Control.description_md5(description)}
    ]
    |> Enum.reject(fn {_name, value} -> value in [nil, ""] end)
    |> Enum.map_join(fn {name, value} -> "#{name}: #{value}\n" end)
  end

  @doc """
  The pool path for a package, relative to the repository root.

  Debian shards on the first letter, except that `lib*` gets its own
  two-character shard — `libfoo` under `libf/`, not `l/` — because
  otherwise `l/` holds most of the archive.
  """
  def pool_path(deb) do
    name = deb.control["Package"] || "unknown"
    Path.join(["pool", deb.component, shard(name), name, deb.file.filename])
  end

  defp shard("lib" <> <<first::utf8>> <> _rest), do: "lib" <> <<first::utf8>>
  defp shard(<<first::utf8>> <> _rest), do: <<first::utf8>>
  defp shard(_), do: "unknown"

  defp release_document(repo, dist) do
    debs = repo |> debs() |> Enum.filter(&(&1.dist == dist))

    components = debs |> Enum.map(& &1.component) |> Enum.uniq() |> Enum.sort()
    components = if components == [], do: [@default_component], else: components

    architectures =
      debs
      |> Enum.map(& &1.architecture)
      |> Enum.uniq()
      |> Enum.sort()

    # `all` on its own is not something a client can install from;
    # list a concrete architecture alongside so the index is usable.
    architectures = if architectures == [], do: [@arch_all], else: architectures

    label = repo_label(repo)

    header =
      [
        {"Origin", label},
        {"Label", label},
        {"Suite", dist},
        {"Codename", dist},
        {"Date", http_date()},
        {"Architectures", Enum.join(architectures, " ")},
        {"Components", Enum.join(components, " ")},
        {"Description", "#{label} — packages published from the forge"}
      ]
      |> Enum.map_join(fn {name, value} -> "#{name}: #{value}\n" end)

    header <> "SHA256:\n" <> checksums(repo, dist, components, architectures)
  end

  # Every index file the Release covers, with the size and SHA-256 of
  # each — compressed *and* uncompressed, which the format requires
  # even though apt will only fetch one of them.
  defp checksums(repo, dist, components, architectures) do
    for component <- components, arch <- architectures, into: "" do
      body = packages_document(repo, dist, component, arch)
      base = "#{component}/binary-#{arch}/Packages"

      compressed =
        case Native.xz_compress(body) do
          {:ok, bytes} -> [{base <> ".xz", bytes}]
          {:error, _} -> []
        end

      Enum.map_join([{base, body} | compressed], fn {path, bytes} ->
        digest = :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)
        # Field width matters only for readability; apt splits on
        # whitespace.
        " #{digest} #{byte_size(bytes)} #{path}\n"
      end)
    end
  end

  defp repo_label(%Repository{} = repo) do
    repo = GitGud.Repo.preload(repo, [:owner, :organization])
    "#{Storage.repo_handle(repo)}/#{repo.name}"
  end

  # RFC 1123 in UTC, which is what dpkg writes and apt parses.
  defp http_date do
    DateTime.utc_now()
    |> Calendar.strftime("%a, %d %b %Y %H:%M:%S UTC")
  end

  @doc "Default distribution when an upload doesn't name one."
  def default_dist, do: @default_dist

  @doc "Default component when an upload doesn't name one."
  def default_component, do: @default_component

  # ── uploads ─────────────────────────────────────────────────────────

  @impl true
  def store(repo, ctx, body)

  def store(repo, %{method: "POST", path: path, conn: conn} = ctx, body)
      when is_binary(body) and (path == ["upload"] or path == []) do
    params = Plug.Conn.fetch_query_params(conn).query_params
    dist = blank_to_nil(params["dist"]) || @default_dist
    component = blank_to_nil(params["component"]) || @default_component

    with {:ok, control} <- Control.parse(body) do
      name = control["Package"]
      version = control["Version"]
      architecture = control["Architecture"] || @arch_all

      attrs = %{
        filename: "#{name}_#{version}_#{architecture}.deb",
        body: body,
        content_type: content_type(name),
        metadata: %{
          "dist" => dist,
          "component" => component,
          "architecture" => architecture,
          "control" => control
        },
        version_metadata: %{"control" => control},
        uploader: uploader(ctx)
      }

      case Packages.publish_file(repo, @format, name, version, attrs) do
        {:ok, _package, _version, _file} ->
          {:ok, "application/json",
           Jason.encode_to_iodata!(%{
             "package" => name,
             "version" => version,
             "architecture" => architecture,
             "dist" => dist,
             "component" => component
           })}

        {:error, :file_exists} ->
          {:error, :file_exists}

        {:error, reason} ->
          {:error, {400, inspect(reason)}}
      end
    end
  end

  def store(_repo, %{method: "POST"}, _body),
    do: {:error, {400, "POST the raw .deb to /upload"}}

  def store(_repo, %{method: method}, _body),
    do: {:error, {405, "#{method} is not supported by the apt repository"}}

  defp blank_to_nil(value) when is_binary(value) do
    case String.trim(value) do
      "" -> nil
      trimmed -> trimmed
    end
  end

  defp blank_to_nil(_), do: nil

  defp uploader(%{principal: {:user, user}}), do: user
  defp uploader(_ctx), do: nil
end