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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.6 KiB · text
History
b868907