Serve an F-Droid index

631f976 · Gabriel Morell · 2026-09-12 17:17

12 files +731 -2
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