12.0 KiB · text History 8808a78
defmodule GitGud.ApkFixtures do
  @moduledoc """
  Synthetic but structurally real APK pieces: a `resources.arsc` and a
  zip with the alignment padding a real one carries.

  Both are built rather than lifted, for the reason `GitGud.RpmFixtures`
  gives: the real article is 400 KB of resource table or 13 MB of APK,
  and the parsers read a few hundred bytes of structure out of either.
  A generated fixture also lets a test say "two densities and an XML"
  and get exactly that, which no real APK offers on request.

  The parsers were developed against the real thing — this repo's own
  `org.neiam.gitgud.app`, release-built and resource-shrunk — and these
  reproduce its shape: an icon whose resource id resolves to several
  files, one per density, plus the adaptive-icon XML that must not win.
  """

  @doc """
  A compiled `AndroidManifest.xml` with `android:icon` and
  `android:label` as resource references, which is how a real one
  carries them — the checked-in fixture from this repo's debug build
  declares neither, so resolving them can't be tested against it.

  Takes the two ids so a test can point them at whatever
  `resources_arsc/0` defines.
  """
  def manifest(icon_ref, label_ref) do
    strings = [
      "package",
      "versionCode",
      "icon",
      "label",
      "manifest",
      "application",
      "org.neiam.gitgud.app"
    ]

    pool = string_pool(strings)

    manifest_element =
      element(4, [{0, {:string, 6}}, {1, {:int, 7}}])

    application_element =
      element(5, [{2, {:reference, icon_ref}}, {3, {:reference, label_ref}}])

    body = pool <> manifest_element <> application_element
    <<0x0003::little-16, 8::little-16, byte_size(body) + 8::little-32>> <> body
  end

  @doc """
  A zip shaped like an APK: a compiled manifest, a resource table, and
  the icon at the two-character path R8 leaves behind.
  """
  def resource_shrunk_apk(icon_ref, label_ref, png) do
    entries = [
      {~c"AndroidManifest.xml", manifest(icon_ref, label_ref)},
      {~c"resources.arsc", resources_arsc(icon_ref, label_ref)},
      {~c"res/hi.png", png},
      {~c"res/lo.png", "a lower density, which should lose"},
      {~c"res/ad.xml", "<adaptive-icon/>, which is not servable"}
    ]

    {:ok, {_name, zip}} = :zip.create(~c"shrunk.apk", entries, [:memory])
    zip
  end

  @doc """
  A `resources.arsc` where:

    * `0x7F010000` is a file reference with three values — two PNGs at
      different densities and an XML at `anydpi`
    * `0x7F020000` is a string, the app label

  Paths are the two-character names R8 rewrites resources to, because
  that is what the reader has to cope with.
  """
  def resources_arsc(icon_id \\ 0x7F010000, label_id \\ 0x7F020000) do
    # The global pool holds every value: file paths and strings alike.
    strings = ["res/lo.png", "res/hi.png", "res/ad.xml", "GitGud"]
    pool = string_pool(strings)

    # The icon: one entry, three configurations.
    {icon_type, icon_entry} = {type_id(icon_id), entry_index(icon_id)}

    icon =
      [
        type_chunk(icon_type, 160, [{icon_entry, 0}]),
        type_chunk(icon_type, 640, [{icon_entry, 1}]),
        type_chunk(icon_type, 0xFFFE, [{icon_entry, 2}])
      ]
      |> IO.iodata_to_binary()

    # The label, in the default configuration.
    label = type_chunk(type_id(label_id), 0, [{entry_index(label_id), 3}])

    package = package_chunk(0x7F, [icon, label])

    # The header is 8 bytes of chunk header plus the package count, and
    # `size` covers the whole table including it.
    body = <<1::little-32>> <> pool <> package
    <<0x0002::little-16, 12::little-16, byte_size(body) + 8::little-32>> <> body
  end

  @doc """
  A zip holding `name` stored (uncompressed) with one byte of alignment
  padding in its local extra field — the shape `zipalign` produces and
  `:zip.unzip/2` raises on.
  """
  def aligned_zip(name, data) do
    name = to_string(name)
    crc = :erlang.crc32(data)
    extra = <<0>>

    local =
      <<0x04034B50::little-32, 20::little-16, 0::little-16, 0::little-16, 0::little-16,
        0::little-16, crc::little-32, byte_size(data)::little-32, byte_size(data)::little-32,
        byte_size(name)::little-16, byte_size(extra)::little-16>> <> name <> extra <> data

    central =
      <<0x02014B50::little-32, 20::little-16, 20::little-16, 0::little-16, 0::little-16,
        0::little-16, 0::little-16, crc::little-32, byte_size(data)::little-32,
        byte_size(data)::little-32, byte_size(name)::little-16, 0::little-16, 0::little-16,
        0::little-16, 0::little-16, 0::little-32, 0::little-32>> <> name

    eocd =
      <<0x06054B50::little-32, 0::little-16, 0::little-16, 1::little-16, 1::little-16,
        byte_size(central)::little-32, byte_size(local)::little-32, 0::little-16>>

    local <> central <> eocd
  end

  defp type_id(resource_id), do: Bitwise.band(Bitwise.bsr(resource_id, 16), 0xFF)
  defp entry_index(resource_id), do: Bitwise.band(resource_id, 0xFFFF)

  @cert File.read!("test/support/fixtures/apk/debug-signer.der")
  @manifest File.read!("test/support/fixtures/apk/gitgud-AndroidManifest.xml")

  @magic "APK Sig Block 42"
  @v2_id 0x7109871A

  @doc """
  The certificate `signed_apk/1` signs with, for a test asserting on the
  signer fingerprint the index reports.
  """
  def signing_cert, do: @cert

  @doc """
  This repo's own compiled `AndroidManifest.xml`, from its debug build.
  """
  def compiled_manifest, do: @manifest

  @doc """
  A tiny but real PNG, so what comes back out of a publish is checkable.
  """
  def icon_png, do: <<137, 80, 78, 71, 13, 10, 26, 10>> <> :binary.copy(<<7>>, 40)

  @doc """
  An APK the adapter will accept: this repo's real compiled manifest, a
  native library, launcher icons by the conventional name, a resource
  table carrying the label that manifest points at, and a v2 signing
  block spliced in where an APK keeps one.

  Real APKs are 19 MB and the parsers this feeds have their own tests
  against real input. What a fixture is for is the adapter.
  """
  def signed_apk(extra_entries \\ []) do
    entries =
      [
        {~c"AndroidManifest.xml", @manifest},
        {~c"lib/arm64-v8a/libfoo.so", "not really an elf"},
        # What AGP emits for a debug build. The adapter falls back to
        # this convention when a resource table can't answer, so a
        # fixture without it wouldn't exercise the fallback.
        {~c"res/mipmap-xxxhdpi-v4/ic_launcher.png", icon_png()},
        {~c"res/mipmap-mdpi-v4/ic_launcher.png", "low density, should lose"},
        {~c"res/mipmap-xxxhdpi-v4/ic_launcher_foreground.png", "a layer, not the icon"},
        # The label the real manifest points at (0x7F0A0001) lives here,
        # as it does in an APK: the manifest carries an id, not a name.
        {~c"resources.arsc", resources_arsc(0x7F010000, 0x7F0A0001)}
      ] ++ extra_entries

    signed_zip(entries)
  end

  @doc """
  Signs exactly the entries given, for a test that needs an APK
  *missing* something the standard fixture carries.
  """
  def signed_zip(entries) do
    {:ok, {_name, zip}} = :zip.create(~c"t.apk", entries, [:memory])
    splice_signing_block(zip)
  end

  defp lp(data), do: <<byte_size(data)::little-32, data::binary>>

  defp scheme_value(der), do: lp(lp(lp(lp("digests") <> lp(lp(der)))))

  defp signing_block(pairs) do
    size = byte_size(pairs) + 24
    <<size::little-64, pairs::binary, size::little-64, @magic::binary>>
  end

  defp pair(id, value), do: <<byte_size(value) + 4::little-64, id::little-32, value::binary>>

  # Splice a v2 signing block in where an APK keeps one, fixing the
  # EOCD's pointer to the central directory by the block's length.
  defp splice_signing_block(zip) do
    block = signing_block(pair(@v2_id, scheme_value(@cert)))

    eocd = find_eocd(zip, byte_size(zip) - 22)
    <<_::binary-size(16), cd_offset::little-32, _::binary>> = binary_part(zip, eocd, 22)

    body =
      binary_part(zip, 0, cd_offset) <>
        block <> binary_part(zip, cd_offset, byte_size(zip) - cd_offset)

    prefix = eocd + byte_size(block) + 16
    <<head::binary-size(^prefix), _old::little-32, tail::binary>> = body
    head <> <<cd_offset + byte_size(block)::little-32>> <> tail
  end

  defp find_eocd(zip, pos) do
    case binary_part(zip, pos, 4) do
      <<0x50, 0x4B, 0x05, 0x06>> -> pos
      _ -> find_eocd(zip, pos - 1)
    end
  end

  # ── chunk builders ───────────────────────────────────────────────────

  # RES_XML_START_ELEMENT: the node header, the attribute extension,
  # then fixed-size attributes.
  defp element(name_index, attrs) do
    # ResXMLTree_node: the chunk header, a line number and a comment
    # index — 16 bytes, which is where the attribute extension starts.
    header_size = 16
    attr_size = 20

    ext =
      <<0xFFFFFFFF::little-32, name_index::little-32, 20::little-16, attr_size::little-16,
        length(attrs)::little-16, 0::little-16, 0::little-16, 0::little-16>>

    body = Enum.map_join(attrs, &attribute/1)
    size = header_size + byte_size(ext) + byte_size(body)

    <<0x0102::little-16, header_size::little-16, size::little-32, 0::little-32, 0::little-32>> <>
      ext <> body
  end

  # ns, name, raw value, then the typed value: 0x03 string, 0x10 int,
  # 0x01 reference.
  defp attribute({name_index, {kind, value}}) do
    {type, raw} =
      case kind do
        :string -> {0x03, value}
        :int -> {0x10, 0xFFFFFFFF}
        :reference -> {0x01, 0xFFFFFFFF}
      end

    <<0xFFFFFFFF::little-32, name_index::little-32, raw::little-32, 8::little-16, 0, type,
      value::little-32>>
  end

  # A UTF-8 string pool: offsets, then each string as
  # {utf16 length, byte length, bytes, NUL}.
  defp string_pool(strings) do
    {data, offsets} =
      Enum.reduce(strings, {<<>>, []}, fn string, {data, offsets} ->
        encoded = <<String.length(string), byte_size(string)>> <> string <> <<0>>
        {data <> encoded, [byte_size(data) | offsets]}
      end)

    offsets = offsets |> Enum.reverse() |> Enum.map(&<<&1::little-32>>) |> IO.iodata_to_binary()
    data = pad4(data)

    header_size = 28
    strings_start = header_size + byte_size(offsets)
    size = strings_start + byte_size(data)

    <<0x0001::little-16, header_size::little-16, size::little-32, length(strings)::little-32,
      0::little-32, 0x100::little-32, strings_start::little-32, 0::little-32>> <> offsets <> data
  end

  # A package, carrying the empty type and key pools a real one has
  # between its header and its type chunks.
  defp package_chunk(id, type_chunks) do
    header_size = 284
    inner = string_pool([]) <> string_pool([]) <> IO.iodata_to_binary(type_chunks)
    size = header_size + byte_size(inner)

    <<0x0200::little-16, header_size::little-16, size::little-32, id::little-32,
      0::size(256)-unit(8), 0::little-32, 0::little-32, 0::little-32, 0::little-32>> <> inner
  end

  # One type in one configuration: `{entry index, global pool index}`
  # pairs, each stored as a simple string-typed value.
  defp type_chunk(type_id, density, entries) do
    config = <<28::little-32, 0::little-32, 0::little-32, 0, 0, density::little-16>> <> <<0::96>>
    header_size = 20 + byte_size(config)
    count = entries |> Enum.map(&elem(&1, 0)) |> Enum.max() |> Kernel.+(1)

    {offsets, data} =
      Enum.reduce(0..(count - 1), {<<>>, <<>>}, fn index, {offsets, data} ->
        case List.keyfind(entries, index, 0) do
          {^index, value} ->
            entry =
              <<8::little-16, 0::little-16, 0::little-32, 8::little-16, 0, 0x03,
                value::little-32>>

            {offsets <> <<byte_size(data)::little-32>>, data <> entry}

          nil ->
            {offsets <> <<0xFFFFFFFF::little-32>>, data}
        end
      end)

    entries_start = header_size + byte_size(offsets)
    size = entries_start + byte_size(data)

    <<0x0201::little-16, header_size::little-16, size::little-32, type_id, 0, 0::little-16,
      count::little-32, entries_start::little-32>> <> config <> offsets <> data
  end

  defp pad4(data) do
    case rem(byte_size(data), 4) do
      0 -> data
      n -> data <> :binary.copy(<<0>>, 4 - n)
    end
  end
end