4.2 KiB · text History b868907
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