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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.2 KiB · text
History
631f976