13.2 KiB · text History b868907
defmodule GitGudWeb.AptNamespaceTest do
  @moduledoc """
  The namespace-wide apt repository at `_all`.

  A namespace is usually the unit a machine wants: five repositories
  under one organization are five `deb` lines for what an administrator
  thinks of as one publisher. These assert that the aggregate is a real
  apt repository — signed, complete, and filtered by who is asking.

  Organizations and users are the same code path; both are namespaces,
  and both are covered here.
  """
  use GitGudWeb.ConnCase, async: false

  alias GitGud.Packages
  alias GitGud.Packages.Apt.Native
  alias GitGud.Repositories
  alias GitGud.Repositories.Storage

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  # A .deb: an ar archive of debian-binary, control.tar.gz and
  # data.tar.gz, in that order.
  defp deb(fields) do
    control = Enum.map_join(fields, fn {k, v} -> "#{k}: #{v}\n" end)

    ar([
      {"debian-binary", "2.0\n"},
      {"control.tar.gz", :zlib.gzip(tar([{~c"./control", control}]))},
      {"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

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

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

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

  defp publish(repo, package, opts \\ []) do
    fields = [
      Package: package,
      Version: Keyword.get(opts, :version, "1.0.0-1"),
      Architecture: Keyword.get(opts, :arch, "amd64"),
      Maintainer: "Someone <someone@example.com>",
      Description: "the #{package} package"
    ]

    body = deb(fields)

    {:ok, _p, _v, _f} =
      Packages.publish_file(repo, "deb", package, Keyword.get(opts, :version, "1.0.0-1"), %{
        filename:
          "#{package}_#{Keyword.get(opts, :version, "1.0.0-1")}_#{Keyword.get(opts, :arch, "amd64")}.deb",
        body: body,
        metadata: %{
          "dist" => Keyword.get(opts, :dist, "stable"),
          "component" => "main",
          "architecture" => Keyword.get(opts, :arch, "amd64"),
          "control" => Map.new(fields, fn {k, v} -> {to_string(k), v} end)
        }
      })

    body
  end

  defp org_fixture(user) do
    {:ok, org} =
      GitGud.Organizations.create_organization(user, %{
        "handle" => "org#{System.unique_integer([:positive])}",
        "name" => "Test Org"
      })

    org
  end

  defp org_repo(org, user, visibility \\ "public") do
    {:ok, repo} =
      Repositories.create_repository_for_org(org, user, %{
        "name" => unique_repo_name(),
        "visibility" => visibility
      })

    GitGud.Repo.preload(repo, [:owner, :organization])
  end

  setup do
    user = user_fixture()
    org = org_fixture(user)
    tools = org_repo(org, user)
    agents = org_repo(org, user)

    {:ok, user: user, org: org, tools: tools, agents: agents, root: "/pkg/deb/#{org.handle}/_all"}
  end

  describe "aggregation" do
    test "one index carries packages from every repository in the org", %{
      conn: conn,
      tools: tools,
      agents: agents,
      root: root
    } do
      publish(tools, "widget")
      publish(agents, "gadget")

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

      assert body =~ "Package: widget"
      assert body =~ "Package: gadget"
    end

    test "each entry points into its own repository's pool", %{
      conn: conn,
      tools: tools,
      agents: agents,
      root: root
    } do
      publish(tools, "widget")
      publish(agents, "gadget")

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

      assert body =~ "Filename: pool/main/#{tools.name}/w/widget/widget_1.0.0-1_amd64.deb"
      assert body =~ "Filename: pool/main/#{agents.name}/g/gadget/gadget_1.0.0-1_amd64.deb"
    end

    test "two repositories publishing the same filename don't collide", %{
      conn: conn,
      tools: tools,
      agents: agents,
      root: root
    } do
      # Same package name and version in both, different bytes.
      from_tools = publish(tools, "widget", version: "1.0.0-1")
      from_agents = publish(agents, "widget", version: "1.0.0-1", arch: "arm64")

      refute from_tools == from_agents

      index = get(conn, root <> "/dists/stable/main/binary-amd64/Packages").resp_body
      [_, tools_path] = Regex.run(~r/^Filename: (\S+)$/m, index)

      assert get(build_conn(), root <> "/" <> tools_path).resp_body == from_tools

      arm = get(build_conn(), root <> "/dists/stable/main/binary-arm64/Packages").resp_body
      [_, agents_path] = Regex.run(~r/^Filename: (\S+)$/m, arm)

      refute tools_path == agents_path
      assert get(build_conn(), root <> "/" <> agents_path).resp_body == from_agents
    end

    test "Release spans the architectures and suites of the whole namespace", %{
      conn: conn,
      tools: tools,
      agents: agents,
      org: org,
      root: root
    } do
      publish(tools, "widget", arch: "amd64")
      publish(agents, "gadget", arch: "arm64")

      release = get(conn, root <> "/dists/stable/Release").resp_body

      assert release =~ ~r/^Origin: #{org.handle}$/m
      assert release =~ ~r/^Architectures: amd64 arm64$/m
      assert release =~ ~r/^Suite: stable$/m
    end

    test "a suite only one repository uses still resolves", %{
      conn: conn,
      tools: tools,
      root: root
    } do
      publish(tools, "widget", dist: "trixie")

      body = get(conn, root <> "/dists/trixie/main/binary-amd64/Packages").resp_body
      assert body =~ "Package: widget"

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

    test "every path Release lists is served, at the right size and digest", %{
      conn: conn,
      tools: tools,
      agents: agents,
      root: root
    } do
      publish(tools, "widget")
      publish(agents, "gadget", arch: "all")

      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 "signing" do
    test "the aggregate is signed with the same key as each repository", %{
      conn: conn,
      org: org,
      tools: tools,
      root: root
    } do
      publish(tools, "widget")

      key = get(conn, "/pkg/_keys/deb/#{org.handle}").resp_body
      release = get(build_conn(), root <> "/dists/stable/Release").resp_body
      signature = get(build_conn(), root <> "/dists/stable/Release.gpg").resp_body

      assert {:ok, true} = Native.verify_detached(key, release, signature)

      # …and the per-repository view verifies against the very same key,
      # which is the whole point: one keyring entry either way.
      repo_root = "/pkg/deb/#{Storage.repo_handle(tools)}/#{tools.name}"
      repo_release = get(build_conn(), repo_root <> "/dists/stable/Release").resp_body
      repo_sig = get(build_conn(), repo_root <> "/dists/stable/Release.gpg").resp_body

      assert {:ok, true} = Native.verify_detached(key, repo_release, repo_sig)
    end

    test "InRelease is clearsigned", %{conn: conn, tools: tools, root: root} do
      publish(tools, "widget")

      body = get(conn, root <> "/dists/stable/InRelease").resp_body

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

    test "key.asc under _all is the namespace key", %{conn: conn, org: org, root: root} do
      from_all = get(conn, root <> "/key.asc").resp_body

      assert from_all =~ "-----BEGIN PGP PUBLIC KEY BLOCK-----"
      assert get(build_conn(), "/pkg/_keys/deb/#{org.handle}").resp_body == from_all
    end
  end

  describe "visibility" do
    test "an anonymous caller sees only the public repositories", %{
      conn: conn,
      org: org,
      user: user,
      tools: tools,
      root: root
    } do
      private = org_repo(org, user, "private")

      publish(tools, "public-thing")
      publish(private, "secret-thing")

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

      assert body =~ "Package: public-thing"
      refute body =~ "Package: secret-thing"
    end

    test "a repository's own deploy token still doesn't widen the view", %{
      org: org,
      user: user,
      tools: tools,
      root: root
    } do
      other_private = org_repo(org, user, "private")
      publish(tools, "public-thing")
      publish(other_private, "secret-thing")

      # A token for a *different* private repo in the same namespace.
      {:ok, _t, token} = Packages.create_registry_token(tools, "ci", ["pull"])

      body =
        build_conn()
        |> put_req_header("authorization", token)
        |> get(root <> "/dists/stable/main/binary-amd64/Packages")
        |> Map.get(:resp_body)

      assert body =~ "Package: public-thing"
      refute body =~ "Package: secret-thing"
    end

    test "a private package's pool path 404s for a caller who can't read it", %{
      conn: conn,
      org: org,
      user: user,
      root: root
    } do
      private = org_repo(org, user, "private")
      publish(private, "secret-thing")

      path = "pool/main/#{private.name}/s/secret-thing/secret-thing_1.0.0-1_amd64.deb"

      assert get(conn, root <> "/" <> path).status == 404
    end

    test "an empty namespace still serves a valid, signed Release", %{conn: conn, root: root} do
      release = get(conn, root <> "/dists/stable/Release").resp_body

      assert release =~ ~r/^Components: main$/m
      assert release =~ ~r/^SHA256:$/m
      assert get(build_conn(), root <> "/dists/stable/InRelease").status == 200
    end
  end

  describe "user namespaces work the same way" do
    setup do
      user = user_fixture()
      {_u, one} = repository_fixture(%{owner: user, visibility: "public"})
      {_u, two} = repository_fixture(%{owner: user, visibility: "public"})

      preload = &GitGud.Repo.preload(&1, [:owner, :organization])

      {:ok,
       solo: user,
       one: preload.(one),
       two: preload.(two),
       solo_root: "/pkg/deb/#{Storage.owner_handle(user)}/_all"}
    end

    test "one index spans a user's own repositories", %{
      conn: conn,
      one: one,
      two: two,
      solo_root: root
    } do
      publish(one, "alpha")
      publish(two, "beta")

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

      assert body =~ "Package: alpha"
      assert body =~ "Package: beta"
    end

    test "and it is signed by that user's key", %{
      conn: conn,
      solo: solo,
      one: one,
      solo_root: root
    } do
      publish(one, "alpha")

      key = get(conn, "/pkg/_keys/deb/#{Storage.owner_handle(solo)}").resp_body
      release = get(build_conn(), root <> "/dists/stable/Release").resp_body
      signature = get(build_conn(), root <> "/dists/stable/Release.gpg").resp_body

      assert {:ok, true} = Native.verify_detached(key, release, signature)
    end

    test "an org-owned repository is not in its creator's personal namespace", %{
      conn: conn,
      solo: solo,
      solo_root: root
    } do
      org = org_fixture(solo)
      org_owned = org_repo(org, solo)
      publish(org_owned, "org-thing")

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

      refute body =~ "Package: org-thing"
    end
  end

  describe "the marker" do
    test "publishing to _all is refused — a package needs a repository", %{
      conn: conn,
      root: root
    } do
      conn =
        conn
        |> put_req_header("content-type", "application/octet-stream")
        |> post(root <> "/upload", deb(Package: "widget", Version: "1.0"))

      assert conn.status == 405
      assert conn.resp_body =~ "publish to a repository"
    end

    test "a format with no namespace index 404s there", %{conn: conn, org: org} do
      assert get(conn, "/pkg/pypi/#{org.handle}/_all/simple/").status == 404
    end

    test "an unknown namespace 404s", %{conn: conn} do
      assert get(conn, "/pkg/deb/nobody-at-all/_all/dists/stable/Release").status == 404
    end

    test "a repository can never be called _all", %{user: user} do
      assert {:error, changeset} =
               Repositories.create_repository(user, %{"name" => "_all", "visibility" => "public"})

      assert Keyword.has_key?(changeset.errors, :name)
    end
  end
end