10.7 KiB · text History 69f26e0
defmodule GitGud.Packages.DesktopIconTest do
  @moduledoc """
  A .deb or .rpm that installs an application gets the name and icon
  its desktop entry gives it, the way an APK gets its launcher icon.

  The packages are shaped like koco's: `assets/koco.desktop` with
  `Icon=koco`, and `logo.png` installed as
  `/usr/share/icons/hicolor/256x256/apps/koco.png`.
  """
  use GitGud.DataCase, async: false

  import GitGud.ForgeFixtures
  import GitGud.RpmFixtures

  alias GitGud.Packages
  alias GitGud.Packages.About
  alias GitGud.Packages.Apt.Native
  alias GitGud.Packages.Formats.Deb
  alias GitGud.Packages.Formats.Desktop
  alias GitGud.Packages.Formats.Rpm

  setup do
    {_user, repo} = repository_fixture()
    %{repo: repo}
  end

  @desktop """
  [Desktop Entry]
  Type=Application
  Exec=koco
  Icon=koco
  Name=KoCo
  Name[de]=KoKo

  [Desktop Action quit]
  Name=Quit
  Icon=application-exit
  """

  # Enough PNG for anything that reads one: the signature and an IHDR
  # carrying the width, then a marker so tests can tell icons apart.
  defp png(width, tag),
    do: <<0x89, "PNG\r\n", 0x1A, "\n", 13::32, "IHDR", width::32, width::32>> <> tag

  defp koco_files do
    [
      {"./usr/bin/koco", "\x7FELF"},
      {"./usr/share/applications/koco.desktop", @desktop},
      {"./usr/share/icons/hicolor/32x32/apps/koco.png", png(32, "small")},
      {"./usr/share/icons/hicolor/256x256/apps/koco.png", png(256, "large")}
    ]
  end

  # ── .deb ────────────────────────────────────────────────────────────

  defp deb(files, compression \\ :gzip) do
    control =
      "Package: neiam-koco\nVersion: 0.1.2\nArchitecture: amd64\nDescription: A Kodi Controller\n"

    data = tar(files)

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

    ar([
      {"debian-binary", "2.0\n"},
      {"control.tar.gz", :zlib.gzip(tar([{"./control", control}]))},
      data_member
    ])
  end

  # ustar, written by hand: `:erl_tar.create/3` can't make a symlink
  # out of an in-memory member, and symlinks are half of an icon theme.
  defp tar(members) do
    Enum.map_join(members, fn
      {path, {:symlink, target}} -> tar_header(path, ?2, 0, target)
      {path, data} -> tar_header(path, ?0, byte_size(data), "") <> pad512(data)
    end) <> :binary.copy(<<0>>, 1024)
  end

  defp tar_header(path, type, size, link) do
    octal = fn n, width ->
      String.pad_leading(Integer.to_string(n, 8), width - 1, "0") <> <<0>>
    end

    header =
      field(path, 100) <>
        octal.(0o644, 8) <>
        octal.(0, 8) <>
        octal.(0, 8) <>
        octal.(size, 12) <>
        octal.(0, 12) <>
        "        " <>
        <<type>> <>
        field(link, 100) <> "ustar" <> <<0>> <> "00" <> :binary.copy(<<0>>, 32 + 32 + 8 + 8 + 155)

    # The checksum is the byte sum with its own field read as spaces,
    # which is how the header was built; `:erl_tar` checks it.
    sum = for <<byte <- header>>, reduce: 0, do: (acc -> acc + byte)
    <<before::binary-size(148), _::binary-size(8), rest::binary>> = header
    pad512(before <> octal.(sum, 7) <> " " <> rest)
  end

  defp field(text, width), do: text <> :binary.copy(<<0>>, width - byte_size(text))
  defp pad512(bytes), do: bytes <> :binary.copy(<<0>>, rem(512 - rem(byte_size(bytes), 512), 512))

  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_deb(repo, body) do
    ctx = %{
      method: "POST",
      path: ["upload"],
      conn: Plug.Test.conn(:post, "/upload"),
      principal: :anonymous
    }

    {:ok, _type, _reply} = Deb.store(repo, ctx, body)
    [package] = Packages.list_packages(repo, "deb")
    package
  end

  describe "a .deb" do
    test "takes its name and its largest icon from the desktop entry", %{repo: repo} do
      package = publish_deb(repo, deb(koco_files()))

      about = About.for_package(package)
      assert about.title == "KoCo"
      assert about.icon?

      assert {:ok, "image/png", bytes, _sha} = Packages.icon(package)
      assert String.ends_with?(bytes, "large")
    end

    test "follows a symlinked pixmap", %{repo: repo} do
      files = [
        {"./usr/share/applications/koco.desktop", @desktop},
        {"./opt/koco/logo.png", png(128, "logo")},
        {"./usr/share/pixmaps/koco.png", {:symlink, "../../../opt/koco/logo.png"}}
      ]

      package = publish_deb(repo, deb(files, :xz))

      assert {:ok, "image/png", bytes, _sha} = Packages.icon(package)
      assert String.ends_with?(bytes, "logo")
    end

    test "a package with no desktop entry is left as it was", %{repo: repo} do
      package = publish_deb(repo, deb([{"./usr/lib/libthing.so", "lib"}]))

      about = About.for_package(package)
      assert about.title == "neiam-koco"
      refute about.icon?
    end
  end

  # ── .rpm ────────────────────────────────────────────────────────────

  defp rpm(compressor, payload) do
    files = for {"." <> path, _} <- koco_files(), do: path

    build_rpm(%{
      name: "koco",
      dirnames: files |> Enum.map(&(Path.dirname(&1) <> "/")) |> Enum.uniq(),
      dirindexes:
        Enum.map(files, fn path ->
          dirs = files |> Enum.map(&(Path.dirname(&1) <> "/")) |> Enum.uniq()
          Enum.find_index(dirs, &(&1 == Path.dirname(path) <> "/"))
        end),
      basenames: Enum.map(files, &Path.basename/1),
      payloadcompressor: compressor,
      payload: payload
    })
  end

  describe "an .rpm" do
    test "reads its payload for the desktop entry and icon", %{repo: repo} do
      body = rpm("gzip", :zlib.gzip(cpio(koco_files())))
      assert {:ok, _, _} = Rpm.store(repo, %{method: "POST", path: ["upload"]}, body)

      [package] = Packages.list_packages(repo, "rpm")
      assert About.for_package(package).title == "KoCo"
      assert {:ok, "image/png", bytes, _sha} = Packages.icon(package)
      assert String.ends_with?(bytes, "large")
    end

    test "an unreadable payload costs the icon, not the publish", %{repo: repo} do
      body = rpm("xz", "not xz at all")
      assert {:ok, _, _} = Rpm.store(repo, %{method: "POST", path: ["upload"]}, body)

      [package] = Packages.list_packages(repo, "rpm")
      refute About.for_package(package).icon?
    end
  end

  # ── the parts ───────────────────────────────────────────────────────

  test "an entry named for the package wins over another app's" do
    entries = %{
      "/usr/share/applications/zzz-helper.desktop" =>
        {:file, "[Desktop Entry]\nType=Application\nName=Helper\nIcon=helper\n"},
      "/usr/share/applications/org.neiam.Koco.desktop" =>
        {:file, "[Desktop Entry]\nType=Application\nName=KoCo\nIcon=koco\n"},
      "/usr/share/icons/hicolor/48x48/apps/helper.png" => {:file, png(48, "helper")},
      "/usr/share/icons/hicolor/48x48/apps/koco.png" => {:file, png(48, "koco")}
    }

    assert {:ok, %{label: "KoCo", icon: icon}} = Desktop.find(entries, "neiam-koco")
    assert String.ends_with?(icon, "koco")
  end

  test "an icon too large to be sensible loses to a sensible one" do
    entries = %{
      "/usr/share/applications/koco.desktop" => {:file, "[Desktop Entry]\nIcon=koco\n"},
      "/usr/share/icons/hicolor/1024x1024/apps/koco.png" => {:file, png(1024, "huge")},
      "/usr/share/icons/hicolor/128x128/apps/koco.png" => {:file, png(128, "right")}
    }

    assert {:ok, %{icon: icon}} = Desktop.find(entries, "koco")
    assert String.ends_with?(icon, "right")
  end

  test "decompression stops at its ceiling rather than filling memory" do
    {:ok, xz} = Native.xz_compress(:binary.copy("a", 4096))
    assert {:error, _} = Native.xz_decompress_capped(xz, 1024)
    assert {:ok, bytes} = Native.xz_decompress_capped(xz, 4096)
    assert byte_size(bytes) == 4096
  end

  # ── backfill ────────────────────────────────────────────────────────

  # As it would have been stored before the adapters read desktop
  # entries: the artifact, and none of the metadata.
  defp publish_bare(repo, format, name, version, body) do
    {:ok, package, _v, _f} =
      Packages.publish_file(repo, format, name, version, %{
        filename: "#{name}-#{version}.#{format}",
        body: body,
        metadata: %{}
      })

    package
  end

  describe "backfill_icons/1" do
    test "fills in a .deb and an .rpm published before icons", %{repo: repo} do
      deb = publish_bare(repo, "deb", "neiam-koco", "0.1.2", deb(koco_files()))

      rpm =
        publish_bare(repo, "rpm", "koco", "0.1.2-1", rpm("gzip", :zlib.gzip(cpio(koco_files()))))

      refute About.for_package(deb).icon?

      assert {2, 2} = Desktop.backfill_icons()

      for package <- [deb, rpm] do
        assert %{title: "KoCo", icon?: true} = About.for_package(package)
        assert {:ok, "image/png", bytes, _} = Packages.icon(package)
        assert String.ends_with?(bytes, "large")
      end
    end

    test "a dry run writes nothing, and a package with no entry isn't counted", %{repo: repo} do
      deb = publish_bare(repo, "deb", "neiam-koco", "0.1.2", deb(koco_files()))
      _lib = publish_bare(repo, "deb", "libthing", "1.0", deb([{"./usr/lib/libthing.so", "lib"}]))

      parent = self()
      assert {2, 0} = Desktop.backfill_icons(dry_run: true, on_file: &send(parent, {:report, &1}))
      refute About.for_package(deb).icon?

      assert_received {:report, %{package: "neiam-koco", label: "KoCo", icon: {_, "png"}}}
      assert_received {:report, %{package: "libthing", icon: :none, written?: false}}

      assert {2, 1} = Desktop.backfill_icons()
    end

    test "only the newest file of a package, unless asked for all", %{repo: repo} do
      _old = publish_bare(repo, "deb", "neiam-koco", "0.1.1", deb(koco_files()))
      package = publish_bare(repo, "deb", "neiam-koco", "0.1.2", deb(koco_files()))

      assert {1, 1} = Desktop.backfill_icons()
      assert About.for_package(package).icon?
      assert {2, 2} = Desktop.backfill_icons(all: true)
    end
  end
end