defmodule GitGud.Packages.Formats.Fdroid.Zip do
@moduledoc """
Reads one member out of an APK.
`:zip.unzip/2` cannot do it. `zipalign` pads local file headers so
that stored entries start on a 4-byte boundary, and it pads them with
an extra field that is not a well-formed extra field — a run of zero
bytes, one of them a lone `<<0>>` in this forge's own app. Erlang's
parser walks extra fields expecting `{id, size}` pairs and raises a
`function_clause` on the remainder:
:zip.parse_extra(<<0>>) → ** (FunctionClauseError)
Every uncompressed member of a release APK is padded that way, which
is every PNG in it: the icon this exists to fetch, and nothing else
in the archive that matters. `AndroidManifest.xml` is deflated and
needs no padding, which is why the rest of the adapter never hit
this.
The central directory is fine — `:zip.list_dir/1` reads it without
touching a local header — so that is still where names, offsets and
sizes come from. Only the walk from a local header to the bytes
behind it is done here.
Everything is bounds-checked against the archive: an APK is
attacker-supplied, and a member that claims to be longer than the
file it sits in gets `:none` rather than an exception.
"""
@local_header 0x04034B50
@local_header_size 30
@stored 0
@deflated 8
@doc """
The bytes of `name`, or `:none` if it isn't there or can't be read.
"""
@spec read(binary(), String.t()) :: {:ok, binary()} | :none
def read(archive, name) when is_binary(archive) and is_binary(name) do
with {:ok, entries} <- list(archive),
{:ok, offset, compressed_size} <- locate(entries, name) do
read_at(archive, offset, compressed_size)
else
_ -> :none
end
end
def read(_archive, _name), do: :none
@doc """
Every member's name, in central-directory order.
"""
@spec names(binary()) :: [String.t()]
def names(archive) when is_binary(archive) do
case list(archive) do
{:ok, entries} -> Enum.map(entries, fn {name, _, _} -> name end)
_ -> []
end
end
def names(_archive), do: []
defp list(archive) do
case :zip.list_dir(archive) do
{:ok, entries} ->
{:ok,
Enum.flat_map(entries, fn
{:zip_file, name, _info, _comment, offset, compressed_size} ->
[{List.to_string(name), offset, compressed_size}]
_ ->
[]
end)}
_ ->
:none
end
end
defp locate(entries, name) do
case Enum.find(entries, fn {entry, _, _} -> entry == name end) do
{_, offset, compressed_size} -> {:ok, offset, compressed_size}
nil -> :none
end
end
# The local header repeats the central directory's sizes, but an entry
# written with a data descriptor carries zeroes there — so the size
# comes from the central directory and only the variable-length name
# and extra fields are read here.
defp read_at(archive, offset, compressed_size)
when is_integer(offset) and offset >= 0 and is_integer(compressed_size) and
compressed_size >= 0 do
with true <- offset + @local_header_size <= byte_size(archive),
<<@local_header::little-32, _version::little-16, _flags::little-16, method::little-16,
_time::little-16, _date::little-16, _crc::little-32, _declared_size::little-32,
_uncompressed::little-32, name_len::little-16, extra_len::little-16>> <-
binary_part(archive, offset, @local_header_size),
data_start = offset + @local_header_size + name_len + extra_len,
true <- data_start + compressed_size <= byte_size(archive) do
archive
|> binary_part(data_start, compressed_size)
|> decompress(method)
else
_ -> :none
end
end
defp read_at(_archive, _offset, _size), do: :none
defp decompress(data, @stored), do: {:ok, data}
# Raw deflate: a zip member has no zlib wrapper, hence the negative
# window size.
defp decompress(data, @deflated) do
z = :zlib.open()
try do
:zlib.inflateInit(z, -15)
{:ok, z |> :zlib.inflate(data) |> IO.iodata_to_binary()}
rescue
_ -> :none
else
result -> result
after
:zlib.close(z)
end
end
defp decompress(_data, _method), do: :none
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
4.2 KiB · text
History
b868907