12.6 KiB · text History b868907
defmodule GitGud.Packages.Formats.Fdroid.Manifest do
  @moduledoc """
  Reads `AndroidManifest.xml` out of an APK.

  This is the `Formats.Deb.Control` of the F-Droid adapter, and it is
  bigger, because an APK carries no text stanza. The manifest inside an
  APK is Android's compiled binary resource format — a chunk tree over
  a string pool — so there is a small parser here rather than an XML
  library call.

  Only what the index needs is extracted: package name, version, SDK
  levels, permissions, and the icon and label attributes. See
  `docs/fdroid-registry.md` for the chunk layouts.

  `android:icon` and `android:label` are usually `TYPE_REFERENCE` — a
  resource id, meaningless on its own. They are reported raw and
  resolved by `Arsc`, which is the module that can.

  Attributes are matched by their **name string**, not by resource id.
  The resource map chunk maps pool index to resource id and a fully
  correct reader would consult it; the names are in the pool either
  way, and matching them avoids carrying a resource-id table. An APK
  built by a toolchain that stripped attribute names would read as
  empty — none do.

  Everything here is defensive. An APK is attacker-supplied input from
  the registry's point of view, so a malformed chunk returns an error
  rather than raising, and no length read out of the file is trusted to
  be in bounds.
  """

  # Chunk types, from ResourceTypes.h.
  @res_string_pool 0x0001
  @res_xml 0x0003
  @res_xml_start_element 0x0102

  # String pool flags.
  @utf8_flag 0x100

  # Typed-value data types.
  @type_string 0x03
  @type_int_dec 0x10
  @type_int_hex 0x11
  @type_int_boolean 0x12

  @absent 0xFFFFFFFF

  # A manifest far larger than this is not a manifest. The real one for
  # this repo's own app is 8 KB; AOSP's largest are in the low hundreds.
  @max_size 4 * 1024 * 1024

  @type attrs :: %{String.t() => String.t() | integer() | boolean()}

  @type info :: %{
          package: String.t() | nil,
          version_code: integer() | nil,
          version_name: String.t() | nil,
          min_sdk: integer() | nil,
          target_sdk: integer() | nil,
          permissions: [String.t()],
          icon_ref: non_neg_integer() | nil,
          label: String.t() | non_neg_integer() | nil
        }

  @doc """
  Parse a binary `AndroidManifest.xml`.

  Returns `{:ok, info}` or `{:error, {status, message}}` shaped for the
  registry controller.
  """
  @spec parse(binary()) :: {:ok, info()} | {:error, {integer(), String.t()}}
  def parse(data) when is_binary(data) and byte_size(data) > @max_size,
    do: {:error, {422, "AndroidManifest.xml is implausibly large"}}

  def parse(<<@res_xml::little-16, header_size::little-16, size::little-32, _::binary>> = data)
      when header_size >= 8 do
    # The declared size may be shorter than the file (trailing padding
    # is legal); longer means the file is truncated.
    if size > byte_size(data) do
      {:error, {422, "AndroidManifest.xml is truncated"}}
    else
      body = binary_part(data, header_size, size - header_size)

      with {:ok, pool} <- find_string_pool(body),
           {:ok, elements} <- collect_elements(body, pool) do
        {:ok, assemble(elements)}
      end
    end
  end

  def parse(_), do: {:error, {422, "not a binary AndroidManifest.xml"}}

  # ── chunk walking ────────────────────────────────────────────────────

  # Chunks are `type, headerSize, size` then `size - headerSize` of
  # payload. A zero or oversized `size` would loop forever or read past
  # the end, so both stop the walk.
  defp chunks(<<>>, acc), do: {:ok, Enum.reverse(acc)}

  defp chunks(
         <<type::little-16, header_size::little-16, size::little-32, _::binary>> = data,
         acc
       )
       when size >= 8 and header_size >= 8 and header_size <= size do
    if size > byte_size(data) do
      # Trailing garbage after the last well-formed chunk: take what we
      # have rather than failing the whole manifest.
      {:ok, Enum.reverse(acc)}
    else
      chunk = binary_part(data, 0, size)
      rest = binary_part(data, size, byte_size(data) - size)
      chunks(rest, [{type, header_size, chunk} | acc])
    end
  end

  defp chunks(_trailing, acc), do: {:ok, Enum.reverse(acc)}

  defp find_string_pool(body) do
    with {:ok, list} <- chunks(body, []) do
      case Enum.find(list, fn {type, _, _} -> type == @res_string_pool end) do
        {_, header_size, chunk} -> parse_string_pool(chunk, header_size)
        nil -> {:error, {422, "AndroidManifest.xml has no string pool"}}
      end
    end
  end

  defp collect_elements(body, pool) do
    with {:ok, list} <- chunks(body, []) do
      elements =
        list
        |> Enum.filter(fn {type, _, _} -> type == @res_xml_start_element end)
        |> Enum.flat_map(fn {_, header_size, chunk} ->
          case parse_start_element(chunk, header_size, pool) do
            {:ok, name, attrs} -> [{name, attrs}]
            :skip -> []
          end
        end)

      {:ok, elements}
    end
  end

  # ── string pool ──────────────────────────────────────────────────────

  defp parse_string_pool(chunk, header_size) do
    case chunk do
      <<_::binary-size(8), count::little-32, _styles::little-32, flags::little-32,
        strings_start::little-32, _styles_start::little-32, _::binary>> ->
        utf8? = Bitwise.band(flags, @utf8_flag) != 0

        with {:ok, offsets} <- pool_offsets(chunk, header_size, count) do
          data_len = byte_size(chunk) - strings_start

          if strings_start > byte_size(chunk) or data_len < 0 do
            {:error, {422, "string pool points outside its chunk"}}
          else
            data = binary_part(chunk, strings_start, data_len)

            strings =
              offsets
              |> Enum.map(&read_string(data, &1, utf8?))
              |> List.to_tuple()

            {:ok, strings}
          end
        end

      _ ->
        {:error, {422, "malformed string pool header"}}
    end
  end

  defp pool_offsets(chunk, header_size, count) do
    need = count * 4

    if header_size + need > byte_size(chunk) do
      {:error, {422, "string pool offset table is truncated"}}
    else
      offsets =
        for <<off::little-32 <- binary_part(chunk, header_size, need)>>, do: off

      {:ok, offsets}
    end
  end

  # Out-of-range offsets yield "" rather than failing: one unreadable
  # string in a pool of eighty shouldn't cost the whole manifest, and
  # every field we care about is checked for presence afterwards.
  defp read_string(data, offset, _utf8?) when offset >= byte_size(data), do: ""

  defp read_string(data, offset, true) do
    rest = binary_part(data, offset, byte_size(data) - offset)

    # UTF-8 entries carry two independently-extensible lengths: the
    # UTF-16 length first, then the byte length. Only the second says
    # how much to read.
    with {_utf16_len, rest} <- utf8_len(rest),
         {byte_len, rest} <- utf8_len(rest),
         true <- byte_len <= byte_size(rest) do
      binary_part(rest, 0, byte_len)
    else
      _ -> ""
    end
  end

  defp read_string(data, offset, false) do
    rest = binary_part(data, offset, byte_size(data) - offset)

    with {len, rest} <- utf16_len(rest),
         bytes = len * 2,
         true <- bytes <= byte_size(rest) do
      rest
      |> binary_part(0, bytes)
      |> :unicode.characters_to_binary({:utf16, :little})
      |> case do
        s when is_binary(s) -> s
        _ -> ""
      end
    else
      _ -> ""
    end
  end

  # A high bit set means the length spans two units, high half first.
  defp utf8_len(<<hi, lo, rest::binary>>) when Bitwise.band(hi, 0x80) != 0,
    do: {Bitwise.bsl(Bitwise.band(hi, 0x7F), 8) + lo, rest}

  defp utf8_len(<<len, rest::binary>>), do: {len, rest}
  defp utf8_len(_), do: :error

  defp utf16_len(<<hi::little-16, lo::little-16, rest::binary>>)
       when Bitwise.band(hi, 0x8000) != 0,
       do: {Bitwise.bsl(Bitwise.band(hi, 0x7FFF), 16) + lo, rest}

  defp utf16_len(<<len::little-16, rest::binary>>), do: {len, rest}
  defp utf16_len(_), do: :error

  defp pool_at(_pool, @absent), do: nil

  defp pool_at(pool, index) when index < tuple_size(pool), do: elem(pool, index)
  defp pool_at(_pool, _index), do: nil

  # ── elements ─────────────────────────────────────────────────────────

  defp parse_start_element(chunk, header_size, pool) do
    # `header_size` covers ResChunk_header + ResXMLTree_node; the
    # attrExt follows it.
    case chunk do
      <<_::binary-size(^header_size), _ns::little-32, name::little-32, attr_start::little-16,
        attr_size::little-16, attr_count::little-16, _id::little-16, _class::little-16,
        _style::little-16, _::binary>> ->
        case pool_at(pool, name) do
          nil ->
            :skip

          tag ->
            # `attr_start` is relative to the start of the attrExt, not
            # to the chunk.
            base = header_size + attr_start
            {:ok, tag, read_attrs(chunk, base, attr_size, attr_count, pool, %{})}
        end

      _ ->
        :skip
    end
  end

  defp read_attrs(_chunk, _offset, _size, 0, _pool, acc), do: acc

  defp read_attrs(chunk, offset, size, count, pool, acc) when size >= 20 do
    if offset + size > byte_size(chunk) do
      acc
    else
      <<_ns::little-32, name::little-32, raw::little-32, _size::little-16, _res0, data_type,
        data::little-32, _::binary>> = binary_part(chunk, offset, size)

      acc =
        case pool_at(pool, name) do
          nil -> acc
          key -> Map.put(acc, key, typed_value(data_type, data, raw, pool))
        end

      read_attrs(chunk, offset + size, size, count - 1, pool, acc)
    end
  end

  defp read_attrs(_chunk, _offset, _size, _count, _pool, acc), do: acc

  defp typed_value(@type_string, data, raw, pool),
    do: pool_at(pool, data) || pool_at(pool, raw) || ""

  defp typed_value(@type_int_boolean, data, _raw, _pool), do: data != 0
  defp typed_value(type, data, _raw, _pool) when type in [@type_int_dec, @type_int_hex], do: data

  # Anything else — references, dimensions, floats — is reported raw.
  # No field this module promises is one of those, so a caller asking
  # for `versionCode` never sees it; a caller poking at `label` does,
  # and gets an integer resource id, which is the truth.
  defp typed_value(_type, data, _raw, _pool), do: data

  # ── assembly ─────────────────────────────────────────────────────────

  defp assemble(elements) do
    manifest = first_attrs(elements, "manifest")
    uses_sdk = first_attrs(elements, "uses-sdk")
    application = first_attrs(elements, "application")

    %{
      package: string(manifest["package"]),
      version_code: integer(manifest["versionCode"]),
      version_name: string(manifest["versionName"]),
      min_sdk: integer(uses_sdk["minSdkVersion"]),
      target_sdk: integer(uses_sdk["targetSdkVersion"]),
      permissions: permissions(elements),
      # Both are almost always TYPE_REFERENCE — a resource id that means
      # nothing without `resources.arsc`. Reported as-is: `Arsc` turns
      # them into a file path and a name, and a caller without it can
      # tell an unresolved id from an absent attribute.
      icon_ref: reference(application["icon"]),
      label: application["label"]
    }
  end

  # A resource id, or nil for an absent attribute or a literal 0 (which
  # is what a manifest carries for "no icon").
  defp reference(value) when is_integer(value) and value > 0, do: value
  defp reference(_), do: nil

  defp first_attrs(elements, tag) do
    case Enum.find(elements, fn {name, _} -> name == tag end) do
      {_, attrs} -> attrs
      nil -> %{}
    end
  end

  defp permissions(elements) do
    elements
    |> Enum.filter(fn {name, _} -> name == "uses-permission" end)
    |> Enum.map(fn {_, attrs} -> string(attrs["name"]) end)
    |> Enum.reject(&is_nil/1)
    |> Enum.uniq()
    |> Enum.sort()
  end

  defp string(value) when is_binary(value), do: if(value == "", do: nil, else: value)
  defp string(_), do: nil

  defp integer(value) when is_integer(value), do: value

  # `versionCode` is an integer attribute, but a hand-built manifest can
  # carry it as a string and aapt will have kept it as one.
  defp integer(value) when is_binary(value) do
    case Integer.parse(value) do
      {n, ""} -> n
      _ -> nil
    end
  end

  defp integer(_), do: nil
end