3.7 KiB · text History 504e101
defmodule GitGud.Packages.Fdroid.ManifestTest do
  @moduledoc """
  The binary `AndroidManifest.xml` reader, checked against a real one:
  `test/support/fixtures/apk/gitgud-AndroidManifest.xml` is lifted
  verbatim out of this repo's own `app-debug.apk`, so the parser is
  tested against what AAPT2 actually emits rather than against a
  fixture written to match the parser.

  The malformed cases matter as much as the happy path. An APK is
  attacker-supplied input from the registry's point of view, so every
  truncation has to come back as an error rather than as a raise or a
  hang.
  """
  use ExUnit.Case, async: true

  alias GitGud.Packages.Formats.Fdroid.Manifest

  @fixture "test/support/fixtures/apk/gitgud-AndroidManifest.xml"

  defp fixture, do: File.read!(@fixture)

  describe "a real AAPT2 manifest" do
    test "reads the package and version" do
      assert {:ok, info} = Manifest.parse(fixture())

      assert info.package == "org.neiam.gitgud.app"
      assert info.version_code == 1
      assert info.version_name == "0.1.0"
    end

    test "reads the SDK levels declared in build.gradle.kts" do
      assert {:ok, info} = Manifest.parse(fixture())

      assert info.min_sdk == 26
      assert info.target_sdk == 34
    end

    test "reads the permissions the app asks for" do
      assert {:ok, info} = Manifest.parse(fixture())

      assert "android.permission.INTERNET" in info.permissions
      assert "android.permission.CAMERA" in info.permissions
    end

    test "returns permissions sorted and deduplicated" do
      assert {:ok, info} = Manifest.parse(fixture())

      assert info.permissions == Enum.sort(info.permissions)
      assert info.permissions == Enum.uniq(info.permissions)
    end
  end

  describe "rejecting input" do
    test "refuses something that isn't a binary manifest" do
      assert {:error, {422, _}} = Manifest.parse("<?xml version=\"1.0\"?><manifest/>")
      assert {:error, {422, _}} = Manifest.parse(<<>>)
      assert {:error, {422, _}} = Manifest.parse(<<0, 1, 2, 3>>)
    end

    test "refuses a file whose declared size overruns the data" do
      <<head::binary-size(4), _size::little-32, rest::binary>> = fixture()
      lying = <<head::binary, 999_999::little-32, rest::binary>>

      assert {:error, {422, message}} = Manifest.parse(lying)
      assert message =~ "truncated"
    end

    test "refuses an implausibly large file without parsing it" do
      assert {:error, {422, message}} = Manifest.parse(:binary.copy(<<0>>, 5 * 1024 * 1024))
      assert message =~ "implausibly large"
    end

    @tag timeout: 10_000
    test "terminates on every truncation of a real manifest" do
      data = fixture()

      # A zero-length chunk or one whose size runs past the end are the
      # two shapes that could loop or read out of bounds; walking every
      # prefix is the cheap way to be sure neither does.
      for len <- Enum.take_every(1..byte_size(data), 37) do
        prefix = binary_part(data, 0, len)

        case Manifest.parse(prefix) do
          {:ok, info} -> assert is_map(info)
          {:error, {422, _}} -> :ok
        end
      end
    end
  end

  describe "a manifest with nothing in it" do
    test "reports nil fields rather than failing" do
      # A well-formed file chunk with an empty string pool and no
      # elements: legal, useless, and it must not crash the reader.
      pool =
        <<0x0001::little-16, 28::little-16, 28::little-32, 0::little-32, 0::little-32,
          0::little-32, 28::little-32, 0::little-32>>

      size = 8 + byte_size(pool)
      data = <<0x0003::little-16, 8::little-16, size::little-32, pool::binary>>

      assert {:ok, info} = Manifest.parse(data)
      assert info.package == nil
      assert info.version_code == nil
      assert info.permissions == []
    end
  end
end