defmodule GitGud.Packages.Formats.Fdroid.Arsc do
@moduledoc """
Reads `resources.arsc` — enough of it to turn a resource id into the
file it names, or the string it holds.
This is the third binary format in the F-Droid adapter, after the
manifest and the signing block, and for a while the adapter did
without it: `android:icon` compiles to a bare resource id, and
guessing `res/mipmap-xxxhdpi/ic_launcher.png` covers a debug build.
It does not survive a release build. R8's resource shrinking rewrites
every resource path, so this forge's own app ships `res/0Z.png` and
`res/2K.9.png` and no `mipmap` directory at all. The names live only
in here, which makes this the only way to answer "which of these 72
PNGs is the launcher icon".
## Shape
A table is a header, a global string pool holding every string value
and file path, and one package per id (0x7f for an app's own
resources, 0x01 for android's framework). A package carries two more
pools — type names, key names — then, per type, a `ResTable_type`
chunk for each configuration: one for xxhdpi, one for xxxhdpi, one
for anydpi-v26, and so on. The same resource id therefore has several
values, and picking between them is the caller's problem, which is
why `file_paths/2` returns them ranked rather than picking one.
A resource id is `0xPPTTEEEE`: package, type, entry index.
## Ranking
Highest density first, and any `.xml` last. An adaptive icon lives in
`mipmap-anydpi-v26` as XML that references two more drawables; it is
the *best* icon on a phone and useless to a repository index, which
needs an image it can serve. The PNG next to it is what F-Droid can
show.
## Trust
An APK is attacker-supplied. Every offset read out of the file is
bounds-checked against the chunk it came from, no length is trusted,
and a malformed table returns `{:error, _}` rather than raising —
the same posture as `Manifest`.
"""
import Bitwise
# Chunk types, from ResourceTypes.h.
@res_string_pool 0x0001
@res_table 0x0002
@res_table_package 0x0200
@res_table_type 0x0201
# String pool flags.
@utf8_flag 0x100
# ResTable_type flags.
@flag_sparse 0x01
@flag_offset16 0x02
# ResTable_entry flags.
@entry_complex 0x0001
# Typed-value data types.
@type_string 0x03
# Densities that aren't measurements: "any" is the adaptive-icon
# bucket and "none" means don't scale. Both sort below every real
# density rather than above it, which is what taking them at face
# value (0xFFFE) would do.
@density_any 0xFFFE
@density_none 0xFFFF
# 64 MB of resources is not a table this needs to read.
@max_size 64 * 1024 * 1024
@typedoc "Parsed table: the global value pool plus each package's entries."
@type t :: %{
strings: tuple(),
# %{resource id => [{density, value}]}
entries: %{non_neg_integer() => [{non_neg_integer(), term()}]}
}
@doc """
Parse `resources.arsc`.
Returns `{:ok, table}` or `{:error, {status, message}}` shaped for the
registry controller.
"""
@spec parse(binary()) :: {:ok, t()} | {:error, {integer(), String.t()}}
def parse(data) when is_binary(data) and byte_size(data) > @max_size,
do: {:error, {422, "resources.arsc is implausibly large"}}
def parse(<<@res_table::little-16, header_size::little-16, size::little-32, _::binary>> = data)
when header_size >= 12 do
cond do
size > byte_size(data) ->
{:error, {422, "resources.arsc is truncated"}}
header_size > byte_size(data) ->
{:error, {422, "resources.arsc header overruns the file"}}
true ->
body = binary_part(data, header_size, size - header_size)
with {:ok, chunks} <- chunks(body, []),
{:ok, strings} <- global_pool(chunks) do
entries =
chunks
|> Enum.filter(fn {type, _, _} -> type == @res_table_package end)
|> Enum.reduce(%{}, &package_entries/2)
{:ok, %{strings: strings, entries: entries}}
end
end
end
def parse(_), do: {:error, {422, "not a resources.arsc"}}
@doc """
Every file path resource `id` resolves to, best first.
Empty for an id that isn't a file reference, isn't in the table, or
is a complex value (a style, an array) rather than a single one.
"""
@spec file_paths(t(), non_neg_integer()) :: [String.t()]
def file_paths(%{entries: entries} = table, id) when is_integer(id) do
entries
|> Map.get(id, [])
|> Enum.flat_map(fn {density, value} ->
case resolve_string(table, value) do
nil -> []
path -> [{density, path}]
end
end)
|> Enum.sort_by(fn {density, path} -> {xml_rank(path), -rank_density(density)} end)
|> Enum.map(&elem(&1, 1))
|> Enum.uniq()
end
def file_paths(_, _), do: []
@doc """
The string resource `id` holds, or nil.
Prefers the default configuration, which for a label is the
untranslated one — the index has a single name field and the
publisher's own language is the honest thing to put in it.
"""
@spec string(t(), non_neg_integer()) :: String.t() | nil
def string(%{entries: entries} = table, id) when is_integer(id) do
entries
|> Map.get(id, [])
|> Enum.sort_by(fn {density, _} -> rank_density(density) end)
|> Enum.find_value(fn {_, value} -> resolve_string(table, value) end)
end
def string(_, _), do: nil
# ── values ───────────────────────────────────────────────────────────
defp resolve_string(%{strings: strings}, {:string, index}), do: pool_at(strings, index)
defp resolve_string(_table, _other), do: nil
# `.xml` sorts after everything: it is a real value for an adaptive
# icon and an unservable one for an index.
defp xml_rank(path), do: if(String.ends_with?(path, ".xml"), do: 1, else: 0)
defp rank_density(density) when density in [@density_any, @density_none], do: 0
defp rank_density(density), do: density
# ── chunk walking ────────────────────────────────────────────────────
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
{: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 global_pool(chunks) do
case Enum.find(chunks, fn {type, _, _} -> type == @res_string_pool end) do
{_, header_size, chunk} -> parse_string_pool(chunk, header_size)
nil -> {:error, {422, "resources.arsc has no string pool"}}
end
end
# ── packages ─────────────────────────────────────────────────────────
defp package_entries({_, header_size, chunk}, acc) do
case chunk do
<<_::binary-size(8), id::little-32, _rest::binary>> when header_size <= byte_size(chunk) ->
package_id = band(id, 0xFF)
body = binary_part(chunk, header_size, byte_size(chunk) - header_size)
case chunks(body, []) do
{:ok, inner} ->
inner
|> Enum.filter(fn {type, _, _} -> type == @res_table_type end)
|> Enum.reduce(acc, &type_entries(&1, &2, package_id))
_ ->
acc
end
_ ->
acc
end
end
# One `ResTable_type` is one configuration's worth of one type: every
# mipmap at xxhdpi, say. Entries are addressed by an offset table
# whose encoding depends on the chunk's flags.
defp type_entries({_, header_size, chunk}, acc, package_id) do
case chunk do
<<_::binary-size(8), type_id, flags, _reserved::little-16, entry_count::little-32,
entries_start::little-32, config::binary>>
when type_id > 0 and header_size <= byte_size(chunk) and
entries_start <= byte_size(chunk) ->
density = config_density(config)
table_size = byte_size(chunk) - entries_start
if table_size < 0 or entry_count > table_size do
acc
else
data = binary_part(chunk, entries_start, table_size)
id_base = bsl(package_id, 24) ||| bsl(type_id, 16)
offsets(chunk, header_size, entry_count, flags)
|> Enum.reduce(acc, &add_entry(&1, &2, data, id_base, density))
end
_ ->
acc
end
end
defp add_entry({index, offset}, acc, data, id_base, density) do
case entry_value(data, offset) do
{:ok, value} ->
id = id_base ||| index
Map.update(acc, id, [{density, value}], &[{density, value} | &1])
:none ->
acc
end
end
# `{entry index, offset into the entry data}` for each present entry.
# A 0xFFFFFFFF offset means "not defined in this configuration",
# which is most of them in most configurations.
defp offsets(chunk, header_size, count, flags) do
cond do
band(flags, @flag_sparse) != 0 ->
read_offsets(chunk, header_size, count, 4, fn <<index::little-16, offset::little-16>> ->
{index, offset * 4}
end)
band(flags, @flag_offset16) != 0 ->
read_offsets(chunk, header_size, count, 2, fn <<offset::little-16>> ->
{:sequential, if(offset == 0xFFFF, do: :absent, else: offset * 4)}
end)
|> sequential()
true ->
read_offsets(chunk, header_size, count, 4, fn <<offset::little-32>> ->
{:sequential, if(offset == 0xFFFFFFFF, do: :absent, else: offset)}
end)
|> sequential()
end
end
defp read_offsets(chunk, header_size, count, width, decode) do
need = count * width
if header_size + need > byte_size(chunk) do
[]
else
chunk
|> binary_part(header_size, need)
|> chunk_every_binary(width)
|> Enum.map(decode)
end
end
# The dense encodings list one slot per entry index, in order; the
# sparse one carries its own indices and needs no numbering.
defp sequential(decoded) do
decoded
|> Enum.with_index()
|> Enum.flat_map(fn
{{:sequential, :absent}, _index} -> []
{{:sequential, offset}, index} -> [{index, offset}]
{other, _index} -> [other]
end)
end
defp chunk_every_binary(<<>>, _width), do: []
defp chunk_every_binary(data, width) when byte_size(data) >= width do
[
binary_part(data, 0, width)
| chunk_every_binary(binary_part(data, width, byte_size(data) - width), width)
]
end
defp chunk_every_binary(_data, _width), do: []
# ResTable_entry, then the Res_value that follows it. Complex entries
# (bags: styles, arrays, adaptive-icon *definitions*) carry a map
# instead of a value and are not what a file reference looks like.
defp entry_value(data, offset) when is_integer(offset) and offset >= 0 do
with true <- offset + 8 <= byte_size(data),
<<entry_size::little-16, entry_flags::little-16, _key::little-32, rest::binary>> <-
binary_part(data, offset, byte_size(data) - offset),
false <- band(entry_flags, @entry_complex) != 0,
true <- entry_size >= 8,
value_offset = entry_size - 8,
<<_::binary-size(^value_offset), _size::little-16, _res0, data_type, value::little-32,
_::binary>> <- rest do
case data_type do
@type_string -> {:ok, {:string, value}}
_ -> :none
end
else
_ -> :none
end
end
defp entry_value(_data, _offset), do: :none
# ResTable_config: size, imsi, locale, then screenType — whose third
# field is the density. Anything shorter than that simply has none.
defp config_density(
<<_size::little-32, _imsi::binary-size(4), _locale::binary-size(4), _orientation,
_touchscreen, density::little-16, _::binary>>
),
do: density
defp config_density(_), do: 0
# ── 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? = 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
{:ok, for(<<off::little-32 <- binary_part(chunk, header_size, need)>>, do: off)}
end
end
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)
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)
case rest do
<<len::little-16, body::binary>> ->
# High bit set means a two-word length; the low word follows.
{len, body} =
if band(len, 0x8000) != 0 do
<<low::little-16, body::binary>> = body
{bor(bsl(band(len, 0x7FFF), 16), low), body}
else
{len, body}
end
bytes = len * 2
if bytes <= byte_size(body) do
body |> binary_part(0, bytes) |> utf16_to_utf8()
else
""
end
_ ->
""
end
end
defp utf8_len(<<high, rest::binary>>) when band(high, 0x80) != 0 do
case rest do
<<low, rest::binary>> -> {bor(bsl(band(high, 0x7F), 8), low), rest}
_ -> :error
end
end
defp utf8_len(<<len, rest::binary>>), do: {len, rest}
defp utf8_len(_), do: :error
defp utf16_to_utf8(bytes) do
case :unicode.characters_to_binary(bytes, {:utf16, :little}, :utf8) do
converted when is_binary(converted) -> converted
_ -> ""
end
end
defp pool_at(pool, index) when is_integer(index) and index >= 0 and index < tuple_size(pool),
do: elem(pool, index)
defp pool_at(_pool, _index), do: nil
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
15.0 KiB · text
History
b868907