12.8 KiB · text History 1a40fc9
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.Formats.Fdroid.Jar
  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

  # The signed entry: entry.json wrapped in a JAR whose signature the
  # client verifies against the fingerprint it pinned when the
  # repository was added.
  defp signed_entry(repos, label, scope, ctx) do
    document = entry(repos, label, ctx) |> IO.iodata_to_binary()

    case Jar.build(scope, [{"entry.json", document}]) do
      {:ok, jar, _fingerprint} ->
        {:ok, "application/java-archive", jar}

      {:error, reason} ->
        {:error, {500, "could not sign the index: #{inspect(reason)}"}}
    end
  end

  @doc """
  The SHA-256 of this repository's signing certificate — the
  `?fingerprint=` a client pins when adding the repository.

  Generating the key if there isn't one yet, because the first person
  to ask is usually the packages page trying to print the URL.
  """
  @spec fingerprint(struct()) :: {:ok, String.t()} | {:error, term()}
  def fingerprint(scope) do
    with {:ok, der} <- Jar.certificate(scope), do: {:ok, Jar.fingerprint(der)}
  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