12.2 KiB · text History 631f976
defmodule GitGud.Packages.Formats.Fdroid do
  @moduledoc """
  An F-Droid app repository, per git repository *and* per namespace.

      # everything neiam publishes — one repo entry on the phone
      https://<host>/pkg/fdroid/neiam/_all

      # or one repository on its own
      https://<host>/pkg/fdroid/neiam/gitgud

  Like the apt adapter, this is static documents over HTTP with one
  signature on top. `_all` is the shape that matters: a phone should
  get one repository for a publisher, not one per Android project, and
  `Format.namespace_marker/0` exists for exactly this.

  Paths below either root:

      /entry.jar          signed; the document a client fetches first
      /entry.json         the same content, unsigned — for debugging
      /index-v2.json      the index proper, verified by hash from entry
      /<package>_<code>.apk

  ## Where the fields come from

  An APK carries no metadata file, so everything is read out of the
  binary at publish time by `Fdroid.Apk` — package name, version code
  and name, SDK levels, permissions, ABIs, and the signing
  certificate's SHA-256. See `docs/fdroid-registry.md` for the formats.

  `name`, `summary`, `description` and icons are **not** read. They live
  in `resources.arsc` behind `TYPE_REFERENCE` values, and an ARSC
  reader is a second binary format for prettier listings and nothing
  else. The index falls back to the package name, and the repository
  description comes from the git repository's own.

  ## signer, and why it is the load-bearing field

  F-Droid records the SHA-256 of each APK's signing certificate and
  refuses an update whose signer differs from what is installed. It is
  the thing standing between a repository and someone replacing an app
  on a phone. It is read from the APK rather than asserted.

  ## Timestamps

  Milliseconds since the epoch, which is what F-Droid's index uses
  throughout — seconds would be silently interpreted as 1970 and every
  client would treat the repository as permanently stale.
  """

  @behaviour GitGud.Packages.Format

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Fdroid.Apk
  alias GitGud.Packages.PackageFile
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage

  @format "fdroid"

  # The index-v2 format version F-Droid's own tooling emits.
  @index_version 20_002

  @impl true
  def content_type(_filename), do: "application/vnd.android.package-archive"

  # ── reads ────────────────────────────────────────────────────────────

  @impl true
  def index(repo, ctx), do: serve([repo], repo_label(repo), repo, ctx)

  @impl true
  def namespace_index(namespace, repos, ctx),
    do: serve(repos, Storage.owner_handle(namespace), namespace, ctx)

  defp serve(repos, label, scope, ctx)

  defp serve(repos, label, _scope, %{path: ["index-v2.json"]} = ctx) do
    {:ok, "application/json", index_v2(repos, label, ctx)}
  end

  # The unsigned entry, which is what `curl` wants and what no client
  # will accept. Kept because "is the index right" and "is the
  # signature right" are different questions and debugging them
  # together is miserable.
  defp serve(repos, label, _scope, %{path: ["entry.json"]} = ctx) do
    {:ok, "application/json", entry(repos, label, ctx)}
  end

  defp serve(repos, label, scope, %{path: ["entry.jar"]} = ctx) do
    signed_entry(repos, label, scope, ctx)
  end

  # An APK, by the filename the index points at.
  defp serve(repos, _label, _scope, %{path: [filename]}) do
    case Enum.find(apks(repos), &(&1.file.filename == filename)) do
      %{file: %PackageFile{} = file} -> {:blob, file}
      nil -> {:error, {404, "no such file in this repository"}}
    end
  end

  defp serve(_repos, _label, _scope, %{path: []}),
    do: {:error, {404, "add this URL as a repository in F-Droid; there is no index page"}}

  defp serve(_repos, _label, _scope, _ctx),
    do: {:error, {404, "not found in this repository"}}

  # ── index documents ──────────────────────────────────────────────────

  @doc false
  # index-v2: a repo block, then every package keyed by package name,
  # each with its versions keyed by the APK's sha256.
  def index_v2(repos, label, ctx) do
    apks = apks(repos)
    packages = Enum.group_by(apks, & &1.info["package"])

    Jason.encode_to_iodata!(%{
      "repo" => %{
        "name" => %{"en-US" => label},
        "description" => %{"en-US" => description(repos, label)},
        "address" => ctx.base_url,
        "timestamp" => repo_timestamp(apks),
        "icon" => %{},
        "categories" => %{},
        "mirrors" => []
      },
      "packages" => Map.new(packages, fn {name, group} -> {name, package_entry(name, group)} end)
    })
  end

  defp package_entry(name, group) do
    added = group |> Enum.map(& &1.added) |> Enum.min()
    updated = group |> Enum.map(& &1.added) |> Enum.max()

    %{
      "metadata" => %{
        "added" => added,
        "lastUpdated" => updated,
        # No ARSC reader, so the package name is the name. A repository
        # of one's own builds is a list you already recognise.
        "name" => %{"en-US" => name}
      },
      "versions" => Map.new(group, fn apk -> {apk.file.sha256, version_entry(apk)} end)
    }
  end

  defp version_entry(%{file: file, info: info, added: added}) do
    %{
      "added" => added,
      "file" => %{
        # Leading slash: F-Droid resolves it against the repo address.
        "name" => "/" <> file.filename,
        "sha256" => file.sha256,
        "size" => file.size
      },
      "manifest" =>
        %{
          "versionCode" => info["version_code"],
          "versionName" => info["version_name"],
          "usesSdk" => %{
            "minSdkVersion" => info["min_sdk"],
            "targetSdkVersion" => info["target_sdk"]
          },
          # A list because an APK signed by a rotated key chain has
          # several; we read the current one, so it is a list of one.
          "signer" => %{"sha256" => [info["signer"]]},
          "usesPermission" =>
            Enum.map(info["permissions"] || [], &%{"name" => &1, "maxSdkVersion" => nil}),
          "nativecode" => info["nativecode"] || []
        }
        |> drop_nils()
    }
  end

  @doc false
  # entry.json points at index-v2.json and pins it by hash, so the
  # signature on entry.jar covers the index transitively without the
  # index itself having to be wrapped in a JAR.
  def entry(repos, label, ctx) do
    apks = apks(repos)
    document = index_v2(repos, label, ctx) |> IO.iodata_to_binary()

    Jason.encode_to_iodata!(%{
      "timestamp" => repo_timestamp(apks),
      "version" => @index_version,
      "index" => %{
        "name" => "/index-v2.json",
        "sha256" => :sha256 |> :crypto.hash(document) |> Base.encode16(case: :lower),
        "size" => byte_size(document),
        "numPackages" => apks |> Enum.map(& &1.info["package"]) |> Enum.uniq() |> length()
      }
    })
  end

  # Phase C. Until the JAR signer lands this says so plainly rather
  # than serving something a client would reject with a signature
  # error, which is a much worse way to learn the same fact.
  defp signed_entry(_repos, _label, _scope, _ctx) do
    {:error,
     {501,
      "index signing is not implemented yet — /entry.json and /index-v2.json " <>
        "serve the unsigned documents"}}
  end

  # ── writes ───────────────────────────────────────────────────────────

  @impl true
  def store(repo, ctx, body)

  def store(repo, %{method: "POST", path: path} = ctx, body)
      when is_binary(body) and (path == ["upload"] or path == []) do
    with {:ok, info} <- Apk.read(body) do
      attrs = %{
        filename: Apk.filename(info),
        body: body,
        content_type: content_type(nil),
        metadata: string_keys(info),
        version_metadata: string_keys(info),
        uploader: uploader(ctx)
      }

      case Packages.publish_file(
             repo,
             @format,
             info.package,
             version_label(info),
             attrs
           ) do
        {:ok, _package, _version, _file} ->
          {:ok, "application/json",
           Jason.encode_to_iodata!(%{
             "package" => info.package,
             "versionCode" => info.version_code,
             "versionName" => info.version_name,
             "signer" => info.signer,
             "filename" => Apk.filename(info)
           })}

        {:error, :file_exists} ->
          {:error, :file_exists}

        {:error, reason} ->
          {:error, {400, inspect(reason)}}
      end
    end
  end

  def store(_repo, %{method: "POST"}, _body),
    do: {:error, {400, "POST the raw .apk to /upload"}}

  def store(_repo, %{method: method}, _body),
    do: {:error, {405, "#{method} is not supported by the F-Droid repository"}}

  # ── internals ────────────────────────────────────────────────────────

  # Every published APK across `repos`, with its decoded metadata.
  defp apks(repos) do
    Enum.flat_map(repos, fn %Repository{} = repo ->
      repo
      |> Packages.list_packages(@format)
      |> Enum.flat_map(&Packages.list_package_files/1)
      |> Enum.flat_map(fn %PackageFile{} = file ->
        info = file.metadata || %{}

        # A file whose metadata predates this adapter, or was written by
        # something else, has no package name and cannot be indexed.
        # Skipping beats emitting a null key that breaks the whole
        # document for every other app in the repository.
        if is_binary(info["package"]) do
          [%{file: file, repo_name: repo.name, info: info, added: added_at(file)}]
        else
          []
        end
      end)
    end)
    |> Enum.sort_by(&{&1.info["package"], &1.info["version_code"]})
  end

  defp added_at(%PackageFile{inserted_at: %DateTime{} = at}),
    do: DateTime.to_unix(at, :millisecond)

  defp added_at(_), do: 0

  @doc false
  # When this repository last changed — the newest publish, not `now`.
  #
  # It has to be derived from content, for two reasons. `entry.json`
  # pins `index-v2.json` by hash, so a document that differs per
  # request could never match the hash a client was given, and every
  # fetch would look like tampering. And F-Droid compares this against
  # the timestamp it already holds to refuse rollbacks, which only
  # means anything if it moves when the content does and not otherwise.
  #
  # An empty repository reports 0 rather than the current time: it has
  # never been updated, and saying "just now" every time anyone asks is
  # both false and a rollback the next real publish has to climb out of.
  defp repo_timestamp([]), do: 0
  defp repo_timestamp(apks), do: apks |> Enum.map(& &1.added) |> Enum.max()

  # `versionName (versionCode)` — the version string a human reads in
  # the packages UI. versionCode alone sorts correctly but tells you
  # nothing; versionName alone is not unique.
  defp version_label(%{version_name: nil, version_code: code}), do: to_string(code)
  defp version_label(%{version_name: name, version_code: code}), do: "#{name} (#{code})"

  # Preloaded here rather than assumed: the controller does preload,
  # but a missing association should not be a 500 from an index.
  defp repo_label(%Repository{} = repo) do
    repo = GitGud.Repo.preload(repo, [:owner, :organization])
    "#{Storage.repo_handle(repo)}/#{repo.name}"
  end

  defp description([%Repository{description: d}], _label) when is_binary(d) and d != "", do: d
  defp description(_repos, label), do: "Android packages published by #{label}."

  defp string_keys(info), do: Map.new(info, fn {k, v} -> {to_string(k), stringify(v)} end)

  # `signer_scheme` is an atom; everything else is already JSON-shaped.
  defp stringify(value) when is_atom(value) and not is_boolean(value) and not is_nil(value),
    do: Atom.to_string(value)

  defp stringify(value), do: value

  defp drop_nils(map), do: Map.reject(map, fn {_k, v} -> is_nil(v) end)

  defp uploader(%{principal: {:user, user}}), do: user
  defp uploader(_), do: nil
end