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