14.3 KiB · text History b868907
defmodule GitGudWeb.AptRegistryTest do
  @moduledoc """
  Phase 4: the Debian repository, over HTTP.

  The assertions here are apt's: a signature it can verify against the
  published key, `Release` fields it requires, checksums for the
  uncompressed index as well as the compressed one, and
  `Description-md5`, which apt has refused to do without since 2.7.7.
  """
  use GitGudWeb.ConnCase, async: false

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

  import GitGud.ForgeFixtures

  setup do
    {user, repo} = repository_fixture(%{visibility: "public"})
    {:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"])

    {:ok,
     user: user,
     repo: repo,
     token: token,
     root: "/pkg/deb/#{Storage.owner_handle(user)}/#{repo.name}"}
  end

  # A .deb: an ar archive of debian-binary, control.tar.<ext> and
  # data.tar.gz, in that order.
  defp deb(fields, opts \\ []) do
    compression = Keyword.get(opts, :compression, :gzip)

    control =
      fields
      |> Enum.map_join(fn {k, v} -> "#{k}: #{v}\n" end)

    control_tar = tar([{~c"./control", control}])

    control_member =
      case compression do
        :gzip -> {"control.tar.gz", :zlib.gzip(control_tar)}
        :xz -> {"control.tar.xz", elem(Native.xz_compress(control_tar), 1)}
        :none -> {"control.tar", control_tar}
      end

    ar([
      {"debian-binary", "2.0\n"},
      control_member,
      {"data.tar.gz", :zlib.gzip(tar([{~c"./usr/bin/thing", "#!/bin/sh\n"}]))}
    ])
  end

  defp tar(members) do
    path = Path.join(System.tmp_dir!(), "t-#{System.unique_integer([:positive])}.tar")
    :ok = :erl_tar.create(String.to_charlist(path), members, [])
    File.read!(path)
  end

  # 60-byte fixed-width ASCII headers, data padded to an even offset.
  defp ar(members) do
    body =
      Enum.map_join(members, fn {name, data} ->
        header =
          String.pad_trailing(name, 16) <>
            String.pad_trailing("0", 12) <>
            String.pad_trailing("0", 6) <>
            String.pad_trailing("0", 6) <>
            String.pad_trailing("100644", 8) <>
            String.pad_trailing(Integer.to_string(byte_size(data)), 10) <>
            <<0x60, 0x0A>>

        padding = if rem(byte_size(data), 2) == 1, do: "\n", else: ""
        header <> data <> padding
      end)

    "!<arch>\n" <> body
  end

  defp control_fields(overrides \\ []) do
    Keyword.merge(
      [
        Package: "widget",
        Version: "1.0.0-1",
        Architecture: "amd64",
        Maintainer: "Someone <someone@example.com>",
        "Installed-Size": "128",
        Section: "utils",
        Priority: "optional",
        Depends: "libc6",
        Description: "a widget"
      ],
      overrides
    )
  end

  defp upload(conn, root, token, fields, opts \\ []) do
    query = Keyword.get(opts, :query, "")
    conn = if token, do: put_req_header(conn, "authorization", token), else: conn

    conn
    |> put_req_header("content-type", "application/octet-stream")
    |> post(root <> "/upload" <> query, deb(fields, opts))
  end

  describe "Control.parse/1" do
    test "reads a gzip-compressed control member" do
      assert {:ok, control} = Control.parse(deb(control_fields()))
      assert control["Package"] == "widget"
      assert control["Version"] == "1.0.0-1"
      assert control["Architecture"] == "amd64"
    end

    test "reads an xz-compressed one, which is what dpkg actually produces" do
      assert {:ok, control} = Control.parse(deb(control_fields(), compression: :xz))
      assert control["Package"] == "widget"
    end

    test "reads an uncompressed one" do
      assert {:ok, control} = Control.parse(deb(control_fields(), compression: :none))
      assert control["Package"] == "widget"
    end

    test "keeps a folded Description together" do
      control = """
      Package: widget
      Version: 1.0.0
      Description: a widget
       it does widget things
       across several lines
      """

      assert {:ok, fields} = Control.parse_stanza(control)

      assert fields["Description"] ==
               "a widget\n it does widget things\n across several lines"
    end

    test "rejects something that isn't an ar archive" do
      assert {:error, {400, message}} = Control.parse("not a deb at all")
      assert message =~ "!<arch>"
    end

    test "rejects a stanza with no Package" do
      assert {:error, {400, _}} = Control.parse_stanza("Version: 1.0\n")
    end

    test "description_md5 hashes the newline-terminated body" do
      assert Control.description_md5("a widget") ==
               Base.encode16(:crypto.hash(:md5, "a widget\n"), case: :lower)

      # Trailing newlines are normalised, not doubled.
      assert Control.description_md5("a widget\n") == Control.description_md5("a widget")
    end
  end

  describe "the signing key" do
    test "is an armored OpenPGP certificate", %{conn: conn, root: root} do
      conn = get(conn, root <> "/key.asc")

      assert conn.status == 200
      assert conn.resp_body =~ "-----BEGIN PGP PUBLIC KEY BLOCK-----"
      assert get_resp_header(conn, "content-type") == ["application/pgp-keys"]
    end

    test "is stable — a rotating key breaks every apt client", %{conn: conn, root: root} do
      assert get(conn, root <> "/key.asc").resp_body ==
               get(build_conn(), root <> "/key.asc").resp_body
    end
  end

  describe "Release" do
    setup %{conn: conn, root: root, token: token} do
      upload(conn, root, token, control_fields())
      :ok
    end

    test "carries the fields the format makes mandatory", %{conn: conn, root: root} do
      body = get(conn, root <> "/dists/stable/Release").resp_body

      assert body =~ ~r/^Suite: stable$/m
      assert body =~ ~r/^Codename: stable$/m
      assert body =~ ~r/^Architectures: .*amd64/m
      assert body =~ ~r/^Components: main$/m
      assert body =~ ~r/^Date: \w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} UTC$/m
      assert body =~ ~r/^SHA256:$/m
    end

    test "offers no MD5Sum or SHA1 block — SHA256 is the floor", %{conn: conn, root: root} do
      body = get(conn, root <> "/dists/stable/Release").resp_body

      refute body =~ ~r/^MD5Sum:$/m
      refute body =~ ~r/^SHA1:$/m
    end

    test "lists the uncompressed index as well as the compressed one", %{
      conn: conn,
      root: root
    } do
      body = get(conn, root <> "/dists/stable/Release").resp_body

      assert body =~ "main/binary-amd64/Packages\n"
      assert body =~ "main/binary-amd64/Packages.xz\n"
    end

    test "its checksums match what the server actually serves", %{conn: conn, root: root} do
      release = get(conn, root <> "/dists/stable/Release").resp_body

      listed =
        Regex.scan(~r/^ ([0-9a-f]{64}) +(\d+) +(\S+)$/m, release)
        |> Map.new(fn [_, digest, size, path] -> {path, {digest, String.to_integer(size)}} end)

      assert map_size(listed) > 0

      for {path, {digest, size}} <- listed do
        served = get(build_conn(), root <> "/dists/stable/" <> path)

        assert served.status == 200, "#{path} is listed in Release but not served"
        assert byte_size(served.resp_body) == size, "#{path} size disagrees with Release"

        assert Base.encode16(:crypto.hash(:sha256, served.resp_body), case: :lower) == digest,
               "#{path} checksum disagrees with Release"
      end
    end
  end

  describe "InRelease and Release.gpg" do
    setup %{conn: conn, root: root, token: token} do
      upload(conn, root, token, control_fields())
      :ok
    end

    test "InRelease is clearsigned and contains the Release fields", %{conn: conn, root: root} do
      body = get(conn, root <> "/dists/stable/InRelease").resp_body

      assert body =~ "-----BEGIN PGP SIGNED MESSAGE-----"
      assert body =~ "-----BEGIN PGP SIGNATURE-----"
      assert body =~ "Suite: stable"
    end

    test "Release.gpg verifies against the published key — this is apt's check", %{
      conn: conn,
      root: root
    } do
      key = get(conn, root <> "/key.asc").resp_body
      release = get(build_conn(), root <> "/dists/stable/Release").resp_body
      signature = get(build_conn(), root <> "/dists/stable/Release.gpg").resp_body

      assert signature =~ "-----BEGIN PGP SIGNATURE-----"
      assert {:ok, true} = Native.verify_detached(key, release, signature)
    end

    test "and fails against a tampered Release", %{conn: conn, root: root} do
      key = get(conn, root <> "/key.asc").resp_body
      release = get(build_conn(), root <> "/dists/stable/Release").resp_body
      signature = get(build_conn(), root <> "/dists/stable/Release.gpg").resp_body

      tampered = String.replace(release, "Suite: stable", "Suite: evil")

      assert {:error, _} = Native.verify_detached(key, tampered, signature)
    end
  end

  describe "Packages" do
    test "carries what a stanza requires, Description-md5 included", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      upload(conn, root, token, control_fields())

      body = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body

      assert body =~ ~r/^Package: widget$/m
      assert body =~ ~r/^Version: 1\.0\.0-1$/m
      assert body =~ ~r/^Architecture: amd64$/m

      # The pool path is qualified by the source repository so that a
      # namespace index aggregating several can't collide.
      assert body =~
               "Filename: pool/main/#{repo.name}/w/widget/widget_1.0.0-1_amd64.deb\n"

      assert body =~ ~r/^Size: \d+$/m
      assert body =~ ~r/^SHA256: [0-9a-f]{64}$/m
      # Mandatory as of APT 2.7.7.
      assert body =~ ~r/^Description-md5: [0-9a-f]{32}$/m
    end

    test "a lib package gets its own two-character pool shard", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      upload(conn, root, token, control_fields(Package: "libwidget"))

      body = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body

      assert body =~
               "Filename: pool/main/#{repo.name}/libw/libwidget/libwidget_1.0.0-1_amd64.deb"
    end

    test "arch:all appears in every architecture's index", %{
      conn: conn,
      root: root,
      token: token
    } do
      upload(conn, root, token, control_fields(Package: "widget", Architecture: "amd64"))
      upload(build_conn(), root, token, control_fields(Package: "docs", Architecture: "all"))

      amd64 = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
      all = get(build_conn(), root <> "/dists/stable/main/binary-all/Packages").resp_body

      assert amd64 =~ "Package: widget"
      assert amd64 =~ "Package: docs"
      # …and in binary-all on its own, which is where Debian puts it.
      assert all =~ "Package: docs"
      refute all =~ "Package: widget"
    end

    test "the .xz index decompresses to the plain one", %{conn: conn, root: root, token: token} do
      upload(conn, root, token, control_fields())

      plain = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
      compressed = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages.xz")

      assert get_resp_header(compressed, "content-type") == ["application/x-xz"]
      assert {:ok, ^plain} = Native.xz_decompress(compressed.resp_body)
    end

    test "a package uploaded to another suite stays out of this one", %{
      conn: conn,
      root: root,
      token: token
    } do
      upload(conn, root, token, control_fields(), query: "?dist=unstable")

      assert get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body == ""

      assert get(build_conn(), root <> "/dists/unstable/main/binary-amd64/Packages").resp_body =~
               "Package: widget"
    end
  end

  describe "pool" do
    test "serves the .deb bytes at exactly the path the index names", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      body = deb(control_fields())

      conn
      |> put_req_header("authorization", token)
      |> put_req_header("content-type", "application/octet-stream")
      |> post(root <> "/upload", body)

      # Take the path out of the index rather than reconstructing it —
      # a Filename apt can't fetch is the failure that matters.
      index = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
      [_, filename] = Regex.run(~r/^Filename: (\S+)$/m, index)

      assert filename == "pool/main/#{repo.name}/w/widget/widget_1.0.0-1_amd64.deb"

      served = get(build_conn(), root <> "/" <> filename)

      assert served.status == 200
      assert served.resp_body == body
    end

    test "404 for a .deb nobody uploaded", %{conn: conn, repo: repo, root: root} do
      assert get(conn, root <> "/pool/main/#{repo.name}/g/ghost/ghost_1.0_amd64.deb").status ==
               404
    end

    test "404 for a path that names another repository's pool", %{
      conn: conn,
      root: root,
      token: token
    } do
      upload(conn, root, token, control_fields())

      assert get(build_conn(), root <> "/pool/main/elsewhere/w/widget/widget_1.0.0-1_amd64.deb").status ==
               404
    end
  end

  describe "upload" do
    test "reports where the package landed", %{conn: conn, root: root, token: token} do
      conn =
        upload(conn, root, token, control_fields(), query: "?dist=bookworm&component=contrib")

      assert conn.status == 200

      assert %{
               "package" => "widget",
               "version" => "1.0.0-1",
               "architecture" => "amd64",
               "dist" => "bookworm",
               "component" => "contrib"
             } = Jason.decode!(conn.resp_body)
    end

    test "rejects something that isn't a .deb", %{conn: conn, root: root, token: token} do
      conn =
        conn
        |> put_req_header("authorization", token)
        |> put_req_header("content-type", "application/octet-stream")
        |> post(root <> "/upload", "not a deb")

      assert conn.status == 400
    end

    test "an anonymous upload is refused", %{conn: conn, root: root} do
      assert upload(conn, root, nil, control_fields()).status == 401
    end

    test "409 on re-uploading the same file with different bytes", %{
      conn: conn,
      root: root,
      token: token
    } do
      upload(conn, root, token, control_fields())
      conn = upload(build_conn(), root, token, control_fields(Section: "admin"))

      assert conn.status == 409
    end
  end
end