16.4 KiB · text History ff5c190
defmodule GitGud.Packages.Formats.Deb do
  @moduledoc """
  A Debian repository, per git repository *and* per namespace.

      # everything acme publishes — one line, one key
      deb [signed-by=/etc/apt/keyrings/acme.asc] \\
        https://<host>/pkg/deb/acme/_all stable main

      # or one repository on its own
      deb [signed-by=/etc/apt/keyrings/acme.asc] \\
        https://<host>/pkg/deb/acme/tools stable main

  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`.

  ## `_all`, and why the URL looks like that

  A namespace usually *is* the unit a machine wants. Five repositories
  under one organization are five `deb` lines and five `Packages`
  fetches, for what an administrator thinks of as "acme's packages".
  `_all` collapses them into one repository that aggregates every
  repository in the namespace the caller may read.

  It sits in the repository slot because a repository name must start
  with an alphanumeric (`Repository`'s name format), so `_all` can
  never be a repository and the two can share a URL space. The same
  marker is available to any other format that later wants a
  namespace-wide index.

  A namespace repository is filtered per caller: anonymous sees only
  public repositories, a user sees what they can read, a deploy token
  sees its own repository plus anything public. The `Release` is signed
  at request time, so a filtered view is still a correctly signed one.

  Paths below either 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>/<repo>/<p>/<pkg>/<file>.deb
      /upload?dist=&component=                     POST a .deb (per repo)

  ## 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.Formats.Listing
  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), do: serve([repo], repo_label(repo), repo, ctx)

  @doc """
  The namespace-wide repository at `_all`: the same documents, built
  from every repository in the namespace the caller may read.

  `signing_scope` is the namespace itself, so the aggregate is signed
  with exactly the key that signs each repository individually — one
  keyring entry covers both ways of consuming it.
  """
  @impl true
  def namespace_index(namespace, repos, ctx),
    do: serve(repos, Storage.owner_handle(namespace), namespace, ctx)

  # `signing_scope` is whatever `Packages.signing_key/2` should key on —
  # a repository resolves to its namespace anyway, so both callers end
  # up at the same key.
  defp serve(repos, label, signing_scope, ctx)

  defp serve(_repos, _label, scope, %{path: ["key.asc"]}) do
    with {:ok, %{public_pem: armored}} <- Packages.signing_key(scope, @format) do
      {:ok, "application/pgp-keys", armored}
    end
  end

  defp serve(repos, label, scope, %{path: ["dists", dist, "InRelease"]}) do
    sign(scope, &Native.sign_clearsign/2, release_document(repos, label, dist),
      content_type: "text/plain; charset=utf-8"
    )
  end

  defp serve(repos, label, _scope, %{path: ["dists", dist, "Release"]}),
    do: {:ok, "text/plain; charset=utf-8", release_document(repos, label, dist)}

  defp serve(repos, label, scope, %{path: ["dists", dist, "Release.gpg"]}) do
    sign(scope, &Native.sign_detached/2, release_document(repos, label, dist),
      content_type: "application/pgp-signature"
    )
  end

  defp serve(repos, _label, _scope, %{
         path: ["dists", dist, component, "binary-" <> arch, "Packages"]
       }),
       do: {:ok, "text/plain; charset=utf-8", packages_document(repos, dist, component, arch)}

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

  defp serve(repos, label, _scope, %{path: ["pool" | _] = path} = ctx) do
    # Matched against the whole pool path, not just the filename: two
    # repositories in one namespace may well both publish
    # `widget_1.0_amd64.deb`, and serving the wrong one would be a
    # silent substitution rather than an error.
    wanted = Enum.join(path, "/")

    repos
    |> debs()
    |> Enum.find(&(pool_path(&1) == wanted))
    |> case do
      %{file: %PackageFile{} = file} -> {:blob, file}
      nil -> listing(repos, label, ctx)
    end
  end

  # Anything that isn't a file this repository serves may still be a
  # directory of them.
  defp serve(repos, label, _scope, ctx), do: listing(repos, label, ctx)

  defp sign(scope, signer, document, content_type: content_type) do
    with {:ok, %{private_pem: secret}} <- Packages.signing_key(scope, @format),
         {:ok, signed} <- signer.(secret, document) do
      {:ok, content_type, signed}
    else
      {:error, reason} -> {:error, {500, "could not sign the Release: #{inspect(reason)}"}}
    end
  end

  # ── browsing ────────────────────────────────────────────────────────

  defp listing(repos, label, ctx) do
    Listing.serve(
      served_paths(repos),
      label,
      "Debian repository · apt reads this tree; you can browse it.",
      ctx
    )
  end

  # Every path this repository answers to, with the size and date where
  # we know them for free. Index documents are generated per request, so
  # sizing them here would mean building all of them to draw a table.
  defp served_paths(repos) do
    debs = debs(repos)

    dists =
      debs
      |> Enum.group_by(& &1.dist)
      |> Enum.flat_map(fn {dist, in_dist} ->
        components = in_dist |> Enum.map(& &1.component) |> Enum.uniq()
        architectures = in_dist |> Enum.map(& &1.architecture) |> Enum.uniq()

        ~w(InRelease Release Release.gpg)
        |> Enum.map(&"dists/#{dist}/#{&1}")
        |> Enum.concat(
          for component <- components,
              arch <- architectures,
              name <- ["Packages", "Packages.xz"] do
            "dists/#{dist}/#{component}/binary-#{arch}/#{name}"
          end
        )
      end)

    Enum.map(["key.asc" | dists], &%{path: &1, size: nil, at: nil}) ++
      Enum.map(debs, &%{path: pool_path(&1), size: &1.file.size, at: &1.file.inserted_at})
  end

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

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

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

  defp packages_document(repos, dist, component, arch) do
    repos
    |> 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"], &1.repo_name})
    |> 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.

  Qualified by the source repository, which a stock Debian pool is
  not. It has to be: a namespace repository aggregates several, and two
  of them publishing `widget_1.0_amd64.deb` would otherwise be one path
  with two sets of bytes. `Filename` is opaque to apt, and qualifying
  it in *both* views means the per-repository and namespace indices
  name the same file identically — a client that switches between them
  re-downloads nothing.

  Within that, Debian's own sharding: the first letter, except `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, deb.repo_name, 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(repos, label, dist) do
    debs = repos |> 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()
    architectures = if architectures == [], do: [@arch_all], else: architectures

    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(repos, 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(repos, dist, components, architectures) do
    for component <- components, arch <- architectures, into: "" do
      body = packages_document(repos, 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