neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Serve an F-Droid index
631f976 · Gabriel Morell · 2026-09-12 17:17
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/packages/format.ex
+3
−2
@@ -38,7 +38,7 @@ defmodule GitGud.Packages.Format do
| 38 | 38 | |
| 39 | 39 | alias GitGud.Repositories.Repository |
| 40 | 40 | |
| 41 | − @formats ~w(oci pypi hex cargo deb) | |
| 41 | + @formats ~w(oci pypi hex cargo deb fdroid) | |
| 42 | 42 | |
| 43 | 43 | # Format identifier → adapter module. `oci` is absent on purpose: zot |
| 44 | 44 | # is the authoritative store for container images and serves the |
@@ -49,7 +49,8 @@ defmodule GitGud.Packages.Format do
| 49 | 49 | "pypi" => GitGud.Packages.Formats.Pypi, |
| 50 | 50 | "hex" => GitGud.Packages.Formats.Hex, |
| 51 | 51 | "cargo" => GitGud.Packages.Formats.Cargo, |
| 52 | − "deb" => GitGud.Packages.Formats.Deb | |
| 52 | + "deb" => GitGud.Packages.Formats.Deb, | |
| 53 | + "fdroid" => GitGud.Packages.Formats.Fdroid | |
| 53 | 54 | } |
| 54 | 55 | |
| 55 | 56 | @typedoc """ |
added
lib/git_gud/packages/formats/fdroid.ex
+327
−0
@@ -0,0 +1,327 @@
| 1 | +defmodule GitGud.Packages.Formats.Fdroid do | |
| 2 | + @moduledoc """ | |
| 3 | + An F-Droid app repository, per git repository *and* per namespace. | |
| 4 | + | |
| 5 | + # everything neiam publishes — one repo entry on the phone | |
| 6 | + https://<host>/pkg/fdroid/neiam/_all | |
| 7 | + | |
| 8 | + # or one repository on its own | |
| 9 | + https://<host>/pkg/fdroid/neiam/gitgud | |
| 10 | + | |
| 11 | + Like the apt adapter, this is static documents over HTTP with one | |
| 12 | + signature on top. `_all` is the shape that matters: a phone should | |
| 13 | + get one repository for a publisher, not one per Android project, and | |
| 14 | + `Format.namespace_marker/0` exists for exactly this. | |
| 15 | + | |
| 16 | + Paths below either root: | |
| 17 | + | |
| 18 | + /entry.jar signed; the document a client fetches first | |
| 19 | + /entry.json the same content, unsigned — for debugging | |
| 20 | + /index-v2.json the index proper, verified by hash from entry | |
| 21 | + /<package>_<code>.apk | |
| 22 | + | |
| 23 | + ## Where the fields come from | |
| 24 | + | |
| 25 | + An APK carries no metadata file, so everything is read out of the | |
| 26 | + binary at publish time by `Fdroid.Apk` — package name, version code | |
| 27 | + and name, SDK levels, permissions, ABIs, and the signing | |
| 28 | + certificate's SHA-256. See `docs/fdroid-registry.md` for the formats. | |
| 29 | + | |
| 30 | + `name`, `summary`, `description` and icons are **not** read. They live | |
| 31 | + in `resources.arsc` behind `TYPE_REFERENCE` values, and an ARSC | |
| 32 | + reader is a second binary format for prettier listings and nothing | |
| 33 | + else. The index falls back to the package name, and the repository | |
| 34 | + description comes from the git repository's own. | |
| 35 | + | |
| 36 | + ## signer, and why it is the load-bearing field | |
| 37 | + | |
| 38 | + F-Droid records the SHA-256 of each APK's signing certificate and | |
| 39 | + refuses an update whose signer differs from what is installed. It is | |
| 40 | + the thing standing between a repository and someone replacing an app | |
| 41 | + on a phone. It is read from the APK rather than asserted. | |
| 42 | + | |
| 43 | + ## Timestamps | |
| 44 | + | |
| 45 | + Milliseconds since the epoch, which is what F-Droid's index uses | |
| 46 | + throughout — seconds would be silently interpreted as 1970 and every | |
| 47 | + client would treat the repository as permanently stale. | |
| 48 | + """ | |
| 49 | + | |
| 50 | + @behaviour GitGud.Packages.Format | |
| 51 | + | |
| 52 | + alias GitGud.Packages | |
| 53 | + alias GitGud.Packages.Formats.Fdroid.Apk | |
| 54 | + alias GitGud.Packages.PackageFile | |
| 55 | + alias GitGud.Repositories.Repository | |
| 56 | + alias GitGud.Repositories.Storage | |
| 57 | + | |
| 58 | + @format "fdroid" | |
| 59 | + | |
| 60 | + # The index-v2 format version F-Droid's own tooling emits. | |
| 61 | + @index_version 20_002 | |
| 62 | + | |
| 63 | + @impl true | |
| 64 | + def content_type(_filename), do: "application/vnd.android.package-archive" | |
| 65 | + | |
| 66 | + # ── reads ──────────────────────────────────────────────────────────── | |
| 67 | + | |
| 68 | + @impl true | |
| 69 | + def index(repo, ctx), do: serve([repo], repo_label(repo), repo, ctx) | |
| 70 | + | |
| 71 | + @impl true | |
| 72 | + def namespace_index(namespace, repos, ctx), | |
| 73 | + do: serve(repos, Storage.owner_handle(namespace), namespace, ctx) | |
| 74 | + | |
| 75 | + defp serve(repos, label, scope, ctx) | |
| 76 | + | |
| 77 | + defp serve(repos, label, _scope, %{path: ["index-v2.json"]} = ctx) do | |
| 78 | + {:ok, "application/json", index_v2(repos, label, ctx)} | |
| 79 | + end | |
| 80 | + | |
| 81 | + # The unsigned entry, which is what `curl` wants and what no client | |
| 82 | + # will accept. Kept because "is the index right" and "is the | |
| 83 | + # signature right" are different questions and debugging them | |
| 84 | + # together is miserable. | |
| 85 | + defp serve(repos, label, _scope, %{path: ["entry.json"]} = ctx) do | |
| 86 | + {:ok, "application/json", entry(repos, label, ctx)} | |
| 87 | + end | |
| 88 | + | |
| 89 | + defp serve(repos, label, scope, %{path: ["entry.jar"]} = ctx) do | |
| 90 | + signed_entry(repos, label, scope, ctx) | |
| 91 | + end | |
| 92 | + | |
| 93 | + # An APK, by the filename the index points at. | |
| 94 | + defp serve(repos, _label, _scope, %{path: [filename]}) do | |
| 95 | + case Enum.find(apks(repos), &(&1.file.filename == filename)) do | |
| 96 | + %{file: %PackageFile{} = file} -> {:blob, file} | |
| 97 | + nil -> {:error, {404, "no such file in this repository"}} | |
| 98 | + end | |
| 99 | + end | |
| 100 | + | |
| 101 | + defp serve(_repos, _label, _scope, %{path: []}), | |
| 102 | + do: {:error, {404, "add this URL as a repository in F-Droid; there is no index page"}} | |
| 103 | + | |
| 104 | + defp serve(_repos, _label, _scope, _ctx), | |
| 105 | + do: {:error, {404, "not found in this repository"}} | |
| 106 | + | |
| 107 | + # ── index documents ────────────────────────────────────────────────── | |
| 108 | + | |
| 109 | + @doc false | |
| 110 | + # index-v2: a repo block, then every package keyed by package name, | |
| 111 | + # each with its versions keyed by the APK's sha256. | |
| 112 | + def index_v2(repos, label, ctx) do | |
| 113 | + apks = apks(repos) | |
| 114 | + packages = Enum.group_by(apks, & &1.info["package"]) | |
| 115 | + | |
| 116 | + Jason.encode_to_iodata!(%{ | |
| 117 | + "repo" => %{ | |
| 118 | + "name" => %{"en-US" => label}, | |
| 119 | + "description" => %{"en-US" => description(repos, label)}, | |
| 120 | + "address" => ctx.base_url, | |
| 121 | + "timestamp" => repo_timestamp(apks), | |
| 122 | + "icon" => %{}, | |
| 123 | + "categories" => %{}, | |
| 124 | + "mirrors" => [] | |
| 125 | + }, | |
| 126 | + "packages" => Map.new(packages, fn {name, group} -> {name, package_entry(name, group)} end) | |
| 127 | + }) | |
| 128 | + end | |
| 129 | + | |
| 130 | + defp package_entry(name, group) do | |
| 131 | + added = group |> Enum.map(& &1.added) |> Enum.min() | |
| 132 | + updated = group |> Enum.map(& &1.added) |> Enum.max() | |
| 133 | + | |
| 134 | + %{ | |
| 135 | + "metadata" => %{ | |
| 136 | + "added" => added, | |
| 137 | + "lastUpdated" => updated, | |
| 138 | + # No ARSC reader, so the package name is the name. A repository | |
| 139 | + # of one's own builds is a list you already recognise. | |
| 140 | + "name" => %{"en-US" => name} | |
| 141 | + }, | |
| 142 | + "versions" => Map.new(group, fn apk -> {apk.file.sha256, version_entry(apk)} end) | |
| 143 | + } | |
| 144 | + end | |
| 145 | + | |
| 146 | + defp version_entry(%{file: file, info: info, added: added}) do | |
| 147 | + %{ | |
| 148 | + "added" => added, | |
| 149 | + "file" => %{ | |
| 150 | + # Leading slash: F-Droid resolves it against the repo address. | |
| 151 | + "name" => "/" <> file.filename, | |
| 152 | + "sha256" => file.sha256, | |
| 153 | + "size" => file.size | |
| 154 | + }, | |
| 155 | + "manifest" => | |
| 156 | + %{ | |
| 157 | + "versionCode" => info["version_code"], | |
| 158 | + "versionName" => info["version_name"], | |
| 159 | + "usesSdk" => %{ | |
| 160 | + "minSdkVersion" => info["min_sdk"], | |
| 161 | + "targetSdkVersion" => info["target_sdk"] | |
| 162 | + }, | |
| 163 | + # A list because an APK signed by a rotated key chain has | |
| 164 | + # several; we read the current one, so it is a list of one. | |
| 165 | + "signer" => %{"sha256" => [info["signer"]]}, | |
| 166 | + "usesPermission" => | |
| 167 | + Enum.map(info["permissions"] || [], &%{"name" => &1, "maxSdkVersion" => nil}), | |
| 168 | + "nativecode" => info["nativecode"] || [] | |
| 169 | + } | |
| 170 | + |> drop_nils() | |
| 171 | + } | |
| 172 | + end | |
| 173 | + | |
| 174 | + @doc false | |
| 175 | + # entry.json points at index-v2.json and pins it by hash, so the | |
| 176 | + # signature on entry.jar covers the index transitively without the | |
| 177 | + # index itself having to be wrapped in a JAR. | |
| 178 | + def entry(repos, label, ctx) do | |
| 179 | + apks = apks(repos) | |
| 180 | + document = index_v2(repos, label, ctx) |> IO.iodata_to_binary() | |
| 181 | + | |
| 182 | + Jason.encode_to_iodata!(%{ | |
| 183 | + "timestamp" => repo_timestamp(apks), | |
| 184 | + "version" => @index_version, | |
| 185 | + "index" => %{ | |
| 186 | + "name" => "/index-v2.json", | |
| 187 | + "sha256" => :sha256 |> :crypto.hash(document) |> Base.encode16(case: :lower), | |
| 188 | + "size" => byte_size(document), | |
| 189 | + "numPackages" => apks |> Enum.map(& &1.info["package"]) |> Enum.uniq() |> length() | |
| 190 | + } | |
| 191 | + }) | |
| 192 | + end | |
| 193 | + | |
| 194 | + # Phase C. Until the JAR signer lands this says so plainly rather | |
| 195 | + # than serving something a client would reject with a signature | |
| 196 | + # error, which is a much worse way to learn the same fact. | |
| 197 | + defp signed_entry(_repos, _label, _scope, _ctx) do | |
| 198 | + {:error, | |
| 199 | + {501, | |
| 200 | + "index signing is not implemented yet — /entry.json and /index-v2.json " <> | |
| 201 | + "serve the unsigned documents"}} | |
| 202 | + end | |
| 203 | + | |
| 204 | + # ── writes ─────────────────────────────────────────────────────────── | |
| 205 | + | |
| 206 | + @impl true | |
| 207 | + def store(repo, ctx, body) | |
| 208 | + | |
| 209 | + def store(repo, %{method: "POST", path: path} = ctx, body) | |
| 210 | + when is_binary(body) and (path == ["upload"] or path == []) do | |
| 211 | + with {:ok, info} <- Apk.read(body) do | |
| 212 | + attrs = %{ | |
| 213 | + filename: Apk.filename(info), | |
| 214 | + body: body, | |
| 215 | + content_type: content_type(nil), | |
| 216 | + metadata: string_keys(info), | |
| 217 | + version_metadata: string_keys(info), | |
| 218 | + uploader: uploader(ctx) | |
| 219 | + } | |
| 220 | + | |
| 221 | + case Packages.publish_file( | |
| 222 | + repo, | |
| 223 | + @format, | |
| 224 | + info.package, | |
| 225 | + version_label(info), | |
| 226 | + attrs | |
| 227 | + ) do | |
| 228 | + {:ok, _package, _version, _file} -> | |
| 229 | + {:ok, "application/json", | |
| 230 | + Jason.encode_to_iodata!(%{ | |
| 231 | + "package" => info.package, | |
| 232 | + "versionCode" => info.version_code, | |
| 233 | + "versionName" => info.version_name, | |
| 234 | + "signer" => info.signer, | |
| 235 | + "filename" => Apk.filename(info) | |
| 236 | + })} | |
| 237 | + | |
| 238 | + {:error, :file_exists} -> | |
| 239 | + {:error, :file_exists} | |
| 240 | + | |
| 241 | + {:error, reason} -> | |
| 242 | + {:error, {400, inspect(reason)}} | |
| 243 | + end | |
| 244 | + end | |
| 245 | + end | |
| 246 | + | |
| 247 | + def store(_repo, %{method: "POST"}, _body), | |
| 248 | + do: {:error, {400, "POST the raw .apk to /upload"}} | |
| 249 | + | |
| 250 | + def store(_repo, %{method: method}, _body), | |
| 251 | + do: {:error, {405, "#{method} is not supported by the F-Droid repository"}} | |
| 252 | + | |
| 253 | + # ── internals ──────────────────────────────────────────────────────── | |
| 254 | + | |
| 255 | + # Every published APK across `repos`, with its decoded metadata. | |
| 256 | + defp apks(repos) do | |
| 257 | + Enum.flat_map(repos, fn %Repository{} = repo -> | |
| 258 | + repo | |
| 259 | + |> Packages.list_packages(@format) | |
| 260 | + |> Enum.flat_map(&Packages.list_package_files/1) | |
| 261 | + |> Enum.flat_map(fn %PackageFile{} = file -> | |
| 262 | + info = file.metadata || %{} | |
| 263 | + | |
| 264 | + # A file whose metadata predates this adapter, or was written by | |
| 265 | + # something else, has no package name and cannot be indexed. | |
| 266 | + # Skipping beats emitting a null key that breaks the whole | |
| 267 | + # document for every other app in the repository. | |
| 268 | + if is_binary(info["package"]) do | |
| 269 | + [%{file: file, repo_name: repo.name, info: info, added: added_at(file)}] | |
| 270 | + else | |
| 271 | + [] | |
| 272 | + end | |
| 273 | + end) | |
| 274 | + end) | |
| 275 | + |> Enum.sort_by(&{&1.info["package"], &1.info["version_code"]}) | |
| 276 | + end | |
| 277 | + | |
| 278 | + defp added_at(%PackageFile{inserted_at: %DateTime{} = at}), | |
| 279 | + do: DateTime.to_unix(at, :millisecond) | |
| 280 | + | |
| 281 | + defp added_at(_), do: 0 | |
| 282 | + | |
| 283 | + @doc false | |
| 284 | + # When this repository last changed — the newest publish, not `now`. | |
| 285 | + # | |
| 286 | + # It has to be derived from content, for two reasons. `entry.json` | |
| 287 | + # pins `index-v2.json` by hash, so a document that differs per | |
| 288 | + # request could never match the hash a client was given, and every | |
| 289 | + # fetch would look like tampering. And F-Droid compares this against | |
| 290 | + # the timestamp it already holds to refuse rollbacks, which only | |
| 291 | + # means anything if it moves when the content does and not otherwise. | |
| 292 | + # | |
| 293 | + # An empty repository reports 0 rather than the current time: it has | |
| 294 | + # never been updated, and saying "just now" every time anyone asks is | |
| 295 | + # both false and a rollback the next real publish has to climb out of. | |
| 296 | + defp repo_timestamp([]), do: 0 | |
| 297 | + defp repo_timestamp(apks), do: apks |> Enum.map(& &1.added) |> Enum.max() | |
| 298 | + | |
| 299 | + # `versionName (versionCode)` — the version string a human reads in | |
| 300 | + # the packages UI. versionCode alone sorts correctly but tells you | |
| 301 | + # nothing; versionName alone is not unique. | |
| 302 | + defp version_label(%{version_name: nil, version_code: code}), do: to_string(code) | |
| 303 | + defp version_label(%{version_name: name, version_code: code}), do: "#{name} (#{code})" | |
| 304 | + | |
| 305 | + # Preloaded here rather than assumed: the controller does preload, | |
| 306 | + # but a missing association should not be a 500 from an index. | |
| 307 | + defp repo_label(%Repository{} = repo) do | |
| 308 | + repo = GitGud.Repo.preload(repo, [:owner, :organization]) | |
| 309 | + "#{Storage.repo_handle(repo)}/#{repo.name}" | |
| 310 | + end | |
| 311 | + | |
| 312 | + defp description([%Repository{description: d}], _label) when is_binary(d) and d != "", do: d | |
| 313 | + defp description(_repos, label), do: "Android packages published by #{label}." | |
| 314 | + | |
| 315 | + defp string_keys(info), do: Map.new(info, fn {k, v} -> {to_string(k), stringify(v)} end) | |
| 316 | + | |
| 317 | + # `signer_scheme` is an atom; everything else is already JSON-shaped. | |
| 318 | + defp stringify(value) when is_atom(value) and not is_boolean(value) and not is_nil(value), | |
| 319 | + do: Atom.to_string(value) | |
| 320 | + | |
| 321 | + defp stringify(value), do: value | |
| 322 | + | |
| 323 | + defp drop_nils(map), do: Map.reject(map, fn {_k, v} -> is_nil(v) end) | |
| 324 | + | |
| 325 | + defp uploader(%{principal: {:user, user}}), do: user | |
| 326 | + defp uploader(_), do: nil | |
| 327 | +end |
added
lib/git_gud/packages/formats/fdroid/apk.ex
+136
−0
@@ -0,0 +1,136 @@
| 1 | +defmodule GitGud.Packages.Formats.Fdroid.Apk do | |
| 2 | + @moduledoc """ | |
| 3 | + Everything the F-Droid index needs to know about an uploaded APK, in | |
| 4 | + one pass. | |
| 5 | + | |
| 6 | + Combines the two parsers — `Manifest` for the compiled | |
| 7 | + `AndroidManifest.xml`, `Signature` for the signing certificate — and | |
| 8 | + adds what only the zip container knows: the ABIs a package carries, | |
| 9 | + and its size and digest. | |
| 10 | + | |
| 11 | + Only `AndroidManifest.xml` is decompressed. An APK is tens of | |
| 12 | + megabytes and `:zip.unzip/2` in `:memory` mode would inflate all of | |
| 13 | + it to read one 8 KB member, on a request that is already holding the | |
| 14 | + whole upload in memory. | |
| 15 | + """ | |
| 16 | + | |
| 17 | + alias GitGud.Packages.Formats.Fdroid.Manifest | |
| 18 | + alias GitGud.Packages.Formats.Fdroid.Signature | |
| 19 | + | |
| 20 | + @manifest ~c"AndroidManifest.xml" | |
| 21 | + | |
| 22 | + @type info :: %{ | |
| 23 | + package: String.t(), | |
| 24 | + version_code: integer(), | |
| 25 | + version_name: String.t() | nil, | |
| 26 | + min_sdk: integer() | nil, | |
| 27 | + target_sdk: integer() | nil, | |
| 28 | + permissions: [String.t()], | |
| 29 | + nativecode: [String.t()], | |
| 30 | + signer: String.t(), | |
| 31 | + signer_scheme: :v1 | :v2 | :v3, | |
| 32 | + size: non_neg_integer(), | |
| 33 | + sha256: String.t() | |
| 34 | + } | |
| 35 | + | |
| 36 | + @doc """ | |
| 37 | + Read `body`. Returns `{:ok, info}` or `{:error, {status, message}}` | |
| 38 | + shaped for the registry controller. | |
| 39 | + | |
| 40 | + A package with no name or no version code is refused: F-Droid keys | |
| 41 | + every app on `packageName` and orders updates by `versionCode`, so an | |
| 42 | + APK missing either cannot be indexed, only mis-indexed. | |
| 43 | + """ | |
| 44 | + @spec read(binary()) :: {:ok, info()} | {:error, {integer(), String.t()}} | |
| 45 | + def read(body) when is_binary(body) do | |
| 46 | + with {:ok, manifest_bytes} <- extract_manifest(body), | |
| 47 | + {:ok, manifest} <- Manifest.parse(manifest_bytes), | |
| 48 | + {:ok, package} <- require_package(manifest), | |
| 49 | + {:ok, version_code} <- require_version_code(manifest), | |
| 50 | + {:ok, cert} <- Signature.certificate(body) do | |
| 51 | + {:ok, | |
| 52 | + %{ | |
| 53 | + package: package, | |
| 54 | + version_code: version_code, | |
| 55 | + version_name: manifest.version_name, | |
| 56 | + min_sdk: manifest.min_sdk, | |
| 57 | + target_sdk: manifest.target_sdk, | |
| 58 | + permissions: manifest.permissions, | |
| 59 | + nativecode: nativecode(body), | |
| 60 | + signer: cert.sha256, | |
| 61 | + signer_scheme: cert.scheme, | |
| 62 | + size: byte_size(body), | |
| 63 | + sha256: :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower) | |
| 64 | + }} | |
| 65 | + end | |
| 66 | + end | |
| 67 | + | |
| 68 | + def read(_), do: {:error, {422, "not an APK"}} | |
| 69 | + | |
| 70 | + @doc """ | |
| 71 | + The filename this APK is stored and served under: | |
| 72 | + `<package>_<versionCode>.apk`, which is what `fdroid update` | |
| 73 | + produces and what a human scanning a repository directory expects. | |
| 74 | + """ | |
| 75 | + @spec filename(info()) :: String.t() | |
| 76 | + def filename(%{package: package, version_code: code}), do: "#{package}_#{code}.apk" | |
| 77 | + | |
| 78 | + # ── zip ────────────────────────────────────────────────────────────── | |
| 79 | + | |
| 80 | + defp extract_manifest(body) do | |
| 81 | + case :zip.unzip(body, [:memory, {:file_list, [@manifest]}]) do | |
| 82 | + {:ok, [{@manifest, bytes}]} -> | |
| 83 | + {:ok, bytes} | |
| 84 | + | |
| 85 | + {:ok, []} -> | |
| 86 | + {:error, {422, "APK has no AndroidManifest.xml"}} | |
| 87 | + | |
| 88 | + {:error, reason} -> | |
| 89 | + {:error, {422, "APK is not a readable zip archive (#{inspect(reason)})"}} | |
| 90 | + end | |
| 91 | + rescue | |
| 92 | + # `:zip` raises rather than returning an error on some malformed | |
| 93 | + # central directories. | |
| 94 | + _ -> {:error, {422, "APK is not a readable zip archive"}} | |
| 95 | + end | |
| 96 | + | |
| 97 | + # ABIs, from `lib/<abi>/…` entries. F-Droid shows these so a user on | |
| 98 | + # arm64 isn't offered an x86-only build, and a package with no native | |
| 99 | + # code at all correctly reports none rather than "unknown". | |
| 100 | + defp nativecode(body) do | |
| 101 | + case :zip.list_dir(body) do | |
| 102 | + {:ok, entries} -> | |
| 103 | + entries | |
| 104 | + |> Enum.flat_map(fn | |
| 105 | + {:zip_file, name, _info, _comment, _offset, _comp_size} -> [List.to_string(name)] | |
| 106 | + _ -> [] | |
| 107 | + end) | |
| 108 | + |> Enum.flat_map(fn name -> | |
| 109 | + case String.split(name, "/") do | |
| 110 | + ["lib", abi | _rest] when abi != "" -> [abi] | |
| 111 | + _ -> [] | |
| 112 | + end | |
| 113 | + end) | |
| 114 | + |> Enum.uniq() | |
| 115 | + |> Enum.sort() | |
| 116 | + | |
| 117 | + _ -> | |
| 118 | + [] | |
| 119 | + end | |
| 120 | + rescue | |
| 121 | + _ -> [] | |
| 122 | + end | |
| 123 | + | |
| 124 | + # ── required fields ────────────────────────────────────────────────── | |
| 125 | + | |
| 126 | + defp require_package(%{package: package}) when is_binary(package) and package != "", | |
| 127 | + do: {:ok, package} | |
| 128 | + | |
| 129 | + defp require_package(_), | |
| 130 | + do: {:error, {422, "APK manifest declares no package name"}} | |
| 131 | + | |
| 132 | + defp require_version_code(%{version_code: code}) when is_integer(code), do: {:ok, code} | |
| 133 | + | |
| 134 | + defp require_version_code(_), | |
| 135 | + do: {:error, {422, "APK manifest declares no versionCode"}} | |
| 136 | +end |
added
test/git_gud/packages/fdroid_format_test.exs
+265
−0
@@ -0,0 +1,265 @@
| 1 | +defmodule GitGud.Packages.Formats.FdroidTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The F-Droid adapter: publishing an APK and serving the index it | |
| 4 | + produces. | |
| 5 | + | |
| 6 | + Uploads go through a synthesised APK rather than a real one — a real | |
| 7 | + one is 19 MB and the parsers it exercises have their own tests. What | |
| 8 | + this file is about is the adapter: that a publish lands as a | |
| 9 | + `PackageFile` with the metadata the index needs, that the index | |
| 10 | + document has the shape F-Droid expects, and that `_all` aggregates | |
| 11 | + namespaces the way apt's does. | |
| 12 | + """ | |
| 13 | + use GitGud.DataCase, async: false | |
| 14 | + | |
| 15 | + import GitGud.ForgeFixtures | |
| 16 | + | |
| 17 | + alias GitGud.Packages | |
| 18 | + alias GitGud.Packages.Formats.Fdroid | |
| 19 | + | |
| 20 | + @cert File.read!("test/support/fixtures/apk/debug-signer.der") | |
| 21 | + @manifest File.read!("test/support/fixtures/apk/gitgud-AndroidManifest.xml") | |
| 22 | + | |
| 23 | + @magic "APK Sig Block 42" | |
| 24 | + @v2_id 0x7109871A | |
| 25 | + | |
| 26 | + setup do | |
| 27 | + {user, repo} = repository_fixture() | |
| 28 | + %{user: user, repo: repo} | |
| 29 | + end | |
| 30 | + | |
| 31 | + # ── building an APK ────────────────────────────────────────────────── | |
| 32 | + | |
| 33 | + defp lp(data), do: <<byte_size(data)::little-32, data::binary>> | |
| 34 | + | |
| 35 | + defp scheme_value(der), do: lp(lp(lp(lp("digests") <> lp(lp(der))))) | |
| 36 | + | |
| 37 | + defp signing_block(pairs) do | |
| 38 | + size = byte_size(pairs) + 24 | |
| 39 | + <<size::little-64, pairs::binary, size::little-64, @magic::binary>> | |
| 40 | + end | |
| 41 | + | |
| 42 | + defp pair(id, value), do: <<byte_size(value) + 4::little-64, id::little-32, value::binary>> | |
| 43 | + | |
| 44 | + # A zip carrying the real compiled manifest and a native library, with | |
| 45 | + # a v2 signing block spliced in where an APK keeps one. | |
| 46 | + defp build_apk(extra_entries \\ []) do | |
| 47 | + entries = | |
| 48 | + [ | |
| 49 | + {~c"AndroidManifest.xml", @manifest}, | |
| 50 | + {~c"lib/arm64-v8a/libfoo.so", "not really an elf"} | |
| 51 | + ] ++ extra_entries | |
| 52 | + | |
| 53 | + {:ok, {_name, zip}} = :zip.create(~c"t.apk", entries, [:memory]) | |
| 54 | + block = signing_block(pair(@v2_id, scheme_value(@cert))) | |
| 55 | + | |
| 56 | + eocd = find_eocd(zip, byte_size(zip) - 22) | |
| 57 | + <<_::binary-size(16), cd_offset::little-32, _::binary>> = binary_part(zip, eocd, 22) | |
| 58 | + | |
| 59 | + body = | |
| 60 | + binary_part(zip, 0, cd_offset) <> | |
| 61 | + block <> binary_part(zip, cd_offset, byte_size(zip) - cd_offset) | |
| 62 | + | |
| 63 | + prefix = eocd + byte_size(block) + 16 | |
| 64 | + <<head::binary-size(^prefix), _old::little-32, tail::binary>> = body | |
| 65 | + head <> <<cd_offset + byte_size(block)::little-32>> <> tail | |
| 66 | + end | |
| 67 | + | |
| 68 | + defp find_eocd(zip, pos) do | |
| 69 | + case binary_part(zip, pos, 4) do | |
| 70 | + <<0x50, 0x4B, 0x05, 0x06>> -> pos | |
| 71 | + _ -> find_eocd(zip, pos - 1) | |
| 72 | + end | |
| 73 | + end | |
| 74 | + | |
| 75 | + defp ctx(overrides \\ %{}) do | |
| 76 | + Map.merge( | |
| 77 | + %{ | |
| 78 | + path: [], | |
| 79 | + conn: Plug.Test.conn(:get, "/"), | |
| 80 | + method: "GET", | |
| 81 | + trailing_slash?: false, | |
| 82 | + base_url: "https://forge.test/pkg/fdroid/acme/_all", | |
| 83 | + principal: :anonymous | |
| 84 | + }, | |
| 85 | + overrides | |
| 86 | + ) | |
| 87 | + end | |
| 88 | + | |
| 89 | + defp publish(repo, body) do | |
| 90 | + Fdroid.store(repo, ctx(%{method: "POST", path: ["upload"]}), body) | |
| 91 | + end | |
| 92 | + | |
| 93 | + defp decode({:ok, "application/json", body}), | |
| 94 | + do: body |> IO.iodata_to_binary() |> Jason.decode!() | |
| 95 | + | |
| 96 | + # ── tests ──────────────────────────────────────────────────────────── | |
| 97 | + | |
| 98 | + describe "publishing" do | |
| 99 | + test "stores an APK with the metadata the index needs", %{repo: repo} do | |
| 100 | + assert {:ok, "application/json", body} = publish(repo, build_apk()) | |
| 101 | + reply = body |> IO.iodata_to_binary() |> Jason.decode!() | |
| 102 | + | |
| 103 | + assert reply["package"] == "org.neiam.gitgud.app" | |
| 104 | + assert reply["versionCode"] == 1 | |
| 105 | + assert reply["versionName"] == "0.1.0" | |
| 106 | + assert reply["filename"] == "org.neiam.gitgud.app_1.apk" | |
| 107 | + | |
| 108 | + # The signer is read out of the APK, never asserted by the client. | |
| 109 | + assert reply["signer"] == | |
| 110 | + :sha256 |> :crypto.hash(@cert) |> Base.encode16(case: :lower) | |
| 111 | + | |
| 112 | + assert [package] = Packages.list_packages(repo, "fdroid") | |
| 113 | + assert package.name == "org.neiam.gitgud.app" | |
| 114 | + assert [file] = Packages.list_package_files(package) | |
| 115 | + assert file.metadata["min_sdk"] == 26 | |
| 116 | + assert file.metadata["nativecode"] == ["arm64-v8a"] | |
| 117 | + end | |
| 118 | + | |
| 119 | + test "refuses something that isn't an APK", %{repo: repo} do | |
| 120 | + assert {:error, {422, _}} = publish(repo, "definitely not a zip") | |
| 121 | + end | |
| 122 | + | |
| 123 | + test "refuses anything but POST", %{repo: repo} do | |
| 124 | + assert {:error, {405, _}} = Fdroid.store(repo, ctx(%{method: "DELETE"}), "") | |
| 125 | + end | |
| 126 | + end | |
| 127 | + | |
| 128 | + describe "index-v2" do | |
| 129 | + setup %{repo: repo} do | |
| 130 | + {:ok, _, _} = publish(repo, build_apk()) | |
| 131 | + :ok | |
| 132 | + end | |
| 133 | + | |
| 134 | + test "describes the repository and its packages", %{repo: repo} do | |
| 135 | + assert %{"repo" => r, "packages" => packages} = | |
| 136 | + decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))) | |
| 137 | + | |
| 138 | + assert r["address"] == "https://forge.test/pkg/fdroid/acme/_all" | |
| 139 | + # Milliseconds. Seconds would read as 1970 and every client would | |
| 140 | + # treat the repository as permanently stale. | |
| 141 | + assert r["timestamp"] > 1_700_000_000_000 | |
| 142 | + | |
| 143 | + assert %{"org.neiam.gitgud.app" => app} = packages | |
| 144 | + assert [{sha, version}] = Map.to_list(app["versions"]) | |
| 145 | + assert String.match?(sha, ~r/\A[0-9a-f]{64}\z/) | |
| 146 | + | |
| 147 | + assert version["file"]["name"] == "/org.neiam.gitgud.app_1.apk" | |
| 148 | + assert version["file"]["sha256"] == sha | |
| 149 | + assert version["manifest"]["versionCode"] == 1 | |
| 150 | + assert version["manifest"]["versionName"] == "0.1.0" | |
| 151 | + assert version["manifest"]["usesSdk"] == %{"minSdkVersion" => 26, "targetSdkVersion" => 34} | |
| 152 | + assert version["manifest"]["nativecode"] == ["arm64-v8a"] | |
| 153 | + end | |
| 154 | + | |
| 155 | + test "carries the signing certificate as a list", %{repo: repo} do | |
| 156 | + assert %{"packages" => %{"org.neiam.gitgud.app" => app}} = | |
| 157 | + decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))) | |
| 158 | + | |
| 159 | + [{_sha, version}] = Map.to_list(app["versions"]) | |
| 160 | + expected = :sha256 |> :crypto.hash(@cert) |> Base.encode16(case: :lower) | |
| 161 | + | |
| 162 | + assert version["manifest"]["signer"] == %{"sha256" => [expected]} | |
| 163 | + end | |
| 164 | + | |
| 165 | + test "lists the permissions the manifest declares", %{repo: repo} do | |
| 166 | + assert %{"packages" => %{"org.neiam.gitgud.app" => app}} = | |
| 167 | + decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))) | |
| 168 | + | |
| 169 | + [{_sha, version}] = Map.to_list(app["versions"]) | |
| 170 | + names = Enum.map(version["manifest"]["usesPermission"], & &1["name"]) | |
| 171 | + | |
| 172 | + assert "android.permission.INTERNET" in names | |
| 173 | + assert "android.permission.CAMERA" in names | |
| 174 | + end | |
| 175 | + end | |
| 176 | + | |
| 177 | + describe "entry" do | |
| 178 | + test "pins index-v2.json by hash and size", %{repo: repo} do | |
| 179 | + {:ok, _, _} = publish(repo, build_apk()) | |
| 180 | + | |
| 181 | + entry = decode(Fdroid.index(repo, ctx(%{path: ["entry.json"]}))) | |
| 182 | + index = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})) |> decode_raw() | |
| 183 | + | |
| 184 | + assert entry["version"] == 20_002 | |
| 185 | + assert entry["index"]["name"] == "/index-v2.json" | |
| 186 | + assert entry["index"]["numPackages"] == 1 | |
| 187 | + assert entry["index"]["size"] == byte_size(index) | |
| 188 | + | |
| 189 | + # The signature on entry.jar covers the index transitively, so | |
| 190 | + # this hash is the only thing making that true. | |
| 191 | + assert entry["index"]["sha256"] == | |
| 192 | + :sha256 |> :crypto.hash(index) |> Base.encode16(case: :lower) | |
| 193 | + end | |
| 194 | + | |
| 195 | + test "the index is byte-identical across requests", %{repo: repo} do | |
| 196 | + {:ok, _, _} = publish(repo, build_apk()) | |
| 197 | + | |
| 198 | + first = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})) |> decode_raw() | |
| 199 | + second = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})) |> decode_raw() | |
| 200 | + | |
| 201 | + # Not a nicety. entry.json pins this document by hash, so a | |
| 202 | + # timestamp of `now` would make every fetch look like tampering to | |
| 203 | + # a client that had just been handed the hash — and would make the | |
| 204 | + # sign-at-publish design impossible, since there'd be nothing | |
| 205 | + # stable to sign. | |
| 206 | + assert first == second | |
| 207 | + end | |
| 208 | + | |
| 209 | + test "an empty repository reports a zero timestamp, not the current time", %{repo: repo} do | |
| 210 | + # Claiming "updated just now" on every request is both false and a | |
| 211 | + # rollback that the first real publish has to climb out of. | |
| 212 | + assert %{"repo" => %{"timestamp" => 0}} = | |
| 213 | + decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))) | |
| 214 | + end | |
| 215 | + | |
| 216 | + test "entry.jar says signing isn't implemented rather than serving something unsigned", | |
| 217 | + %{repo: repo} do | |
| 218 | + assert {:error, {501, message}} = Fdroid.index(repo, ctx(%{path: ["entry.jar"]})) | |
| 219 | + assert message =~ "not implemented yet" | |
| 220 | + end | |
| 221 | + end | |
| 222 | + | |
| 223 | + describe "serving APKs" do | |
| 224 | + test "hands back the stored file by the name the index points at", %{repo: repo} do | |
| 225 | + {:ok, _, _} = publish(repo, build_apk()) | |
| 226 | + | |
| 227 | + assert {:blob, file} = Fdroid.index(repo, ctx(%{path: ["org.neiam.gitgud.app_1.apk"]})) | |
| 228 | + assert file.filename == "org.neiam.gitgud.app_1.apk" | |
| 229 | + end | |
| 230 | + | |
| 231 | + test "404s an unknown file", %{repo: repo} do | |
| 232 | + assert {:error, {404, _}} = Fdroid.index(repo, ctx(%{path: ["nope.apk"]})) | |
| 233 | + end | |
| 234 | + | |
| 235 | + test "404s the root with something actionable", %{repo: repo} do | |
| 236 | + assert {:error, {404, message}} = Fdroid.index(repo, ctx(%{path: []})) | |
| 237 | + assert message =~ "add this URL as a repository" | |
| 238 | + end | |
| 239 | + end | |
| 240 | + | |
| 241 | + describe "_all" do | |
| 242 | + test "aggregates every repository in the namespace", %{user: user, repo: repo} do | |
| 243 | + {:ok, other} = | |
| 244 | + GitGud.Repositories.create_repository(user, %{ | |
| 245 | + "name" => "second-app", | |
| 246 | + "visibility" => "public" | |
| 247 | + }) | |
| 248 | + | |
| 249 | + {:ok, _, _} = publish(repo, build_apk()) | |
| 250 | + {:ok, _, _} = publish(other, build_apk([{~c"extra.txt", "makes this a different apk"}])) | |
| 251 | + | |
| 252 | + assert %{"packages" => packages} = | |
| 253 | + decode( | |
| 254 | + Fdroid.namespace_index(user, [repo, other], ctx(%{path: ["index-v2.json"]})) | |
| 255 | + ) | |
| 256 | + | |
| 257 | + # Same package name from two repositories: one app entry, two | |
| 258 | + # versions keyed by their own digests. | |
| 259 | + assert %{"org.neiam.gitgud.app" => app} = packages | |
| 260 | + assert map_size(app["versions"]) == 2 | |
| 261 | + end | |
| 262 | + end | |
| 263 | + | |
| 264 | + defp decode_raw({:ok, _type, body}), do: IO.iodata_to_binary(body) | |
| 265 | +end |
Parents: bc4a319