9.2 KiB · text History 279c2d1
defmodule GitGudWeb.AptBrowseTest do
  @moduledoc """
  The Debian repository as a browsable tree.

  A real mirror is clickable all the way down; ours only answered exact
  paths, so you couldn't see what was published without already knowing
  its name.

  Two properties are worth pinning. The listing must never advertise a
  path that 404s — it is derived from what the repository actually
  serves, and a test walks every link to prove it. And it must never
  show a repository the caller couldn't have read directly.
  """
  use GitGudWeb.ConnCase, async: false

  alias GitGud.Packages
  alias GitGud.Repositories
  alias GitGud.Repositories.Storage

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  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} ->
        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>> <> data <> if(rem(byte_size(data), 2) == 1, do: "\n", else: "")
      end)

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

  defp publish(repo, package, opts \\ []) do
    version = Keyword.get(opts, :version, "1.0.0-1")
    arch = Keyword.get(opts, :arch, "amd64")

    fields = [
      Package: package,
      Version: version,
      Architecture: arch,
      Maintainer: "Someone <someone@example.com>",
      Description: "the #{package} package"
    ]

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

    :ok
  end

  setup do
    {user, repo} = repository_fixture(%{visibility: "public"})
    publish(repo, "widget")

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

  describe "the tree" do
    test "the root lists dists, pool and the key", %{conn: conn, root: root} do
      conn = get(conn, root <> "/")

      assert conn.status == 200
      assert get_resp_header(conn, "content-type") == ["text/html; charset=utf-8"]
      assert conn.resp_body =~ "dists/"
      assert conn.resp_body =~ "pool/"
      assert conn.resp_body =~ "key.asc"
    end

    test "a suite lists its index files and components", %{conn: conn, root: root} do
      body = get(conn, root <> "/dists/stable/").resp_body

      assert body =~ "InRelease"
      assert body =~ "Release.gpg"
      assert body =~ "main/"
    end

    test "an architecture lists the Packages files", %{conn: conn, root: root} do
      body = get(conn, root <> "/dists/stable/main/binary-amd64/").resp_body

      assert body =~ "Packages"
      assert body =~ "Packages.xz"
    end

    test "the pool is navigable down to the .deb, with its size", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      assert get(conn, root <> "/pool/").resp_body =~ "main/"
      assert get(build_conn(), root <> "/pool/main/").resp_body =~ "#{repo.name}/"
      assert get(build_conn(), root <> "/pool/main/#{repo.name}/").resp_body =~ "w/"

      leaf =
        get(build_conn(), root <> "/pool/main/#{repo.name}/w/widget/").resp_body

      assert leaf =~ "widget_1.0.0-1_amd64.deb"
      # A size column, not just a name.
      assert leaf =~ ~r/\d+(\.\d+)? (B|KB|MB)/
    end

    test "every directory offers a parent link except the root", %{conn: conn, root: root} do
      refute get(conn, root <> "/").resp_body =~ ">../<"
      assert get(build_conn(), root <> "/dists/").resp_body =~ ">../<"
    end

    test "a path that is neither a file nor a directory is still a 404", %{
      conn: conn,
      root: root
    } do
      assert get(conn, root <> "/nonsense/").status == 404
      assert get(build_conn(), root <> "/dists/nosuchsuite/").status == 404
      assert get(build_conn(), root <> "/pool/main/nosuchrepo/").status == 404
    end
  end

  describe "the listing never advertises a path that 404s" do
    test "walking every link from the root reaches only things that exist", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      publish(repo, "libthing", arch: "all")
      publish(repo, "gadget", dist: "unstable")

      # Breadth-first over every href the tree offers, following only
      # links below this repository's root.
      {visited, checked} = walk([root <> "/"], MapSet.new(), 0)

      assert checked > 10, "the crawl should have covered the whole tree, saw #{checked}"
      assert MapSet.size(visited) > 10

      # Sanity: the crawl actually reached the leaves.
      assert Enum.any?(visited, &String.ends_with?(&1, ".deb"))
      assert Enum.any?(visited, &String.ends_with?(&1, "/InRelease"))
      assert Enum.any?(visited, &String.ends_with?(&1, "/Packages.xz"))

      _ = conn
    end

    defp walk([], visited, checked), do: {visited, checked}

    defp walk([path | rest], visited, checked) do
      if MapSet.member?(visited, path) do
        walk(rest, visited, checked)
      else
        conn = get(build_conn(), path)

        assert conn.status == 200,
               "#{path} was linked from a listing but answered #{conn.status}"

        visited = MapSet.put(visited, path)

        next =
          if String.ends_with?(path, "/") do
            conn.resp_body
            |> then(&Regex.scan(~r/href="([^"]+)"/, &1))
            |> Enum.map(fn [_, href] -> URI.parse(href).path end)
            # Don't climb back out through `../`.
            |> Enum.reject(&(byte_size(&1) <= byte_size(path)))
          else
            []
          end

        walk(rest ++ next, visited, checked + 1)
      end
    end
  end

  describe "permissions" do
    test "a private repository's tree challenges an anonymous browser", %{conn: conn} do
      {user, private} = repository_fixture(%{visibility: "private"})
      publish(private, "secret")

      root = "/pkg/deb/#{Storage.owner_handle(user)}/#{private.name}"
      conn = get(conn, root <> "/")

      assert conn.status == 401
      assert [_] = get_resp_header(conn, "www-authenticate")
    end

    test "and serves it to a token holder", %{conn: conn} do
      {user, private} = repository_fixture(%{visibility: "private"})
      publish(private, "secret")
      {:ok, _t, token} = Packages.create_registry_token(private, "ro", ["pull"])

      root = "/pkg/deb/#{Storage.owner_handle(user)}/#{private.name}"

      conn =
        conn
        |> put_req_header("authorization", token)
        |> get(root <> "/pool/main/#{private.name}/s/secret/")

      assert conn.status == 200
      assert conn.resp_body =~ "secret_1.0.0-1_amd64.deb"
    end

    test "a namespace listing hides repositories the browser can't read" do
      owner = user_fixture()

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

      {:ok, open} =
        Repositories.create_repository_for_org(org, owner, %{
          "name" => unique_repo_name(),
          "visibility" => "public"
        })

      {:ok, closed} =
        Repositories.create_repository_for_org(org, owner, %{
          "name" => unique_repo_name(),
          "visibility" => "private"
        })

      publish(open, "public-thing")
      publish(closed, "secret-thing")

      root = "/pkg/deb/#{org.handle}/_all"

      anon = get(build_conn(), root <> "/pool/main/").resp_body
      assert anon =~ "#{open.name}/"
      refute anon =~ "#{closed.name}/"

      # …and the path itself is not merely hidden from the index.
      assert get(build_conn(), root <> "/pool/main/#{closed.name}/").status == 404
    end
  end

  describe "apt is unaffected" do
    test "the index files it fetches are still their own content types", %{
      conn: conn,
      root: root
    } do
      release = get(conn, root <> "/dists/stable/Release")
      assert get_resp_header(release, "content-type") == ["text/plain; charset=utf-8"]
      assert release.resp_body =~ "Suite: stable"

      xz = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages.xz")
      assert get_resp_header(xz, "content-type") == ["application/x-xz"]
    end

    test "the pool path from the index is the file, not a listing", %{conn: conn, root: root} do
      index = get(conn, root <> "/dists/stable/main/binary-amd64/Packages").resp_body
      [_, filename] = Regex.run(~r/^Filename: (\S+)$/m, index)

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

      assert served.status == 200
      refute served.resp_body =~ "<!DOCTYPE html>"
    end
  end
end