Serve index-v1, so older F-Droid clients can read the repository

4230fe5 · Gabriel Morell · 2026-09-12 18:35

15 files +185 -4
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages/formats/fdroid.ex
+119 −1
@@ -15,11 +15,21 @@ defmodule GitGud.Packages.Formats.Fdroid do
15 15
16 16 Paths below either root:
17 17
18 − /entry.jar signed; the document a client fetches first
18 + /entry.jar signed; what a v2 client fetches first
19 19 /entry.json the same content, unsigned — for debugging
20 20 /index-v2.json the index proper, verified by hash from entry
21 + /index-v1.jar signed; the whole index, for older clients
22 + /index-v1.json the same, unsigned
21 23 /<package>_<code>.apk
22 24
25 + ## Both index versions, because the failure is invisible
26 +
27 + A client that can't speak v2 falls back to `index-v1.jar` *silently*,
28 + after fetching a perfectly good `entry.jar` — and reports the 404 it
29 + gets. So a repository with a flawless v2 index reads as "invalid
30 + repository / NotFoundException", which points at nothing in
31 + particular. Serving both is the only way that failure doesn't happen.
32 +
23 33 ## Where the fields come from
24 34
25 35 An APK carries no metadata file, so everything is read out of the
@@ -61,6 +71,10 @@ defmodule GitGud.Packages.Formats.Fdroid do
61 71 # The index-v2 format version F-Droid's own tooling emits.
62 72 @index_version 20_002
63 73
74 + # index-v1's own version field. Unrelated to the above beyond looking
75 + # similar — this one is the format the older client understands.
76 + @index_v1_version 20_001
77 +
64 78 @impl true
65 79 def content_type(_filename), do: "application/vnd.android.package-archive"
66 80
@@ -91,6 +105,36 @@ defmodule GitGud.Packages.Formats.Fdroid do
91 105 signed_entry(repos, label, scope, ctx)
92 106 end
93 107
108 + # index-v1, for clients that don't speak v2.
109 + #
110 + # Not optional in practice. A client that can't find this falls back
111 + # to it *silently* after fetching a perfectly good entry.jar, and
112 + # reports the 404 — so a repository with a flawless v2 index reads as
113 + # "invalid repository / NotFoundException", which points at nothing.
114 + defp serve(repos, label, scope, %{path: ["index-v1.jar"]} = ctx) do
115 + document = index_v1(repos, label, ctx) |> IO.iodata_to_binary()
116 +
117 + # SHA-256, not the SHA-1 that fdroidserver historically used here.
118 + #
119 + # A deliberate divergence, and measured: a SHA-1-signed JAR handed
120 + # to a current verifier comes back "treated as unsigned, because it
121 + # is signed with a weak algorithm that is now disabled". On a device
122 + # that is a far worse failure than the 404 this path exists to fix —
123 + # an index that looks present and unsigned rather than absent.
124 + #
125 + # SHA-1 was never a weakness for an index (second-preimage
126 + # resistance is the property that matters), but the platforms have
127 + # stopped caring about that distinction, so this follows them.
128 + case Jar.build(scope, [{"index-v1.json", document}], digest: :sha256) do
129 + {:ok, jar, _fingerprint} -> {:ok, "application/java-archive", jar}
130 + {:error, reason} -> {:error, {500, "could not sign the index: #{inspect(reason)}"}}
131 + end
132 + end
133 +
134 + # The unsigned form, for the same reason entry.json is served.
135 + defp serve(repos, label, _scope, %{path: ["index-v1.json"]} = ctx),
136 + do: {:ok, "application/json", index_v1(repos, label, ctx)}
137 +
94 138 # An APK, by the filename the index points at.
95 139 defp serve(repos, _label, _scope, %{path: [filename]}) do
96 140 case Enum.find(apks(repos), &(&1.file.filename == filename)) do
@@ -172,6 +216,80 @@ defmodule GitGud.Packages.Formats.Fdroid do
172 216 }
173 217 end
174 218
219 + @doc false
220 + # index-v1: one flat document rather than v2's entry-plus-index. The
221 + # shapes differ enough that translating between them would be more
222 + # code than emitting both from the same rows.
223 + def index_v1(repos, label, ctx) do
224 + apks = apks(repos)
225 + by_package = Enum.group_by(apks, & &1.info["package"])
226 +
227 + Jason.encode_to_iodata!(%{
228 + "repo" => %{
229 + "timestamp" => repo_timestamp(apks),
230 + "version" => @index_v1_version,
231 + "name" => label,
232 + "icon" => "",
233 + "address" => ctx.base_url,
234 + "description" => description(repos, label)
235 + },
236 + # Present and empty. The client reads them unconditionally.
237 + "requests" => %{"install" => [], "uninstall" => []},
238 + "apps" => Enum.map(by_package, fn {name, group} -> v1_app(name, group) end),
239 + "packages" =>
240 + Map.new(by_package, fn {name, group} ->
241 + # Newest first: the client takes the head as the suggested
242 + # version, so the order is load-bearing rather than cosmetic.
243 + {name,
244 + group
245 + |> Enum.sort_by(& &1.info["version_code"], :desc)
246 + |> Enum.map(&v1_package/1)}
247 + end)
248 + })
249 + end
250 +
251 + defp v1_app(name, group) do
252 + added = group |> Enum.map(& &1.added) |> Enum.min()
253 + updated = group |> Enum.map(& &1.added) |> Enum.max()
254 + newest = Enum.max_by(group, & &1.info["version_code"])
255 +
256 + %{
257 + "packageName" => name,
258 + "name" => name,
259 + "summary" => "",
260 + "description" => "",
261 + "license" => "Unknown",
262 + "categories" => [],
263 + "added" => added,
264 + "lastUpdated" => updated,
265 + "suggestedVersionCode" => to_string(newest.info["version_code"]),
266 + "suggestedVersionName" => newest.info["version_name"]
267 + }
268 + |> drop_nils()
269 + end
270 +
271 + defp v1_package(%{file: file, info: info, added: added}) do
272 + %{
273 + "apkName" => file.filename,
274 + "hash" => file.sha256,
275 + "hashType" => "sha256",
276 + "versionCode" => info["version_code"],
277 + "versionName" => info["version_name"],
278 + "size" => file.size,
279 + "minSdkVersion" => info["min_sdk"],
280 + "targetSdkVersion" => info["target_sdk"],
281 + # The certificate again. v1 spells it `signer`, and a client
282 + # refuses an update whose signer differs from what's installed.
283 + "signer" => info["signer"],
284 + "added" => added,
285 + "nativecode" => info["nativecode"] || [],
286 + # `[name, maxSdkVersion]` pairs, which is the shape v1 uses —
287 + # not v2's list of objects.
288 + "uses-permission" => Enum.map(info["permissions"] || [], &[&1, nil])
289 + }
290 + |> drop_nils()
291 + end
292 +
175 293 @doc false
176 294 # entry.json points at index-v2.json and pins it by hash, so the
177 295 # signature on entry.jar covers the index transitively without the
modified lib/git_gud/packages/formats/fdroid/jar.ex
+2 −1
@@ -167,7 +167,8 @@ defmodule GitGud.Packages.Formats.Fdroid.Jar do
167 167 defp split_bytes(binary, width),
168 168 do: {binary_part(binary, 0, width), binary_part(binary, width, byte_size(binary) - width)}
169 169
170 − defp base64_digest(content, digest), do: digest |> :crypto.hash(content) |> Base.encode64()
170 + defp base64_digest(content, digest),
171 + do: digest |> Signing.erlang_digest() |> :crypto.hash(content) |> Base.encode64()
171 172
172 173 defp digest_header(:sha1), do: "SHA1-Digest"
173 174 defp digest_header(_sha256), do: "SHA-256-Digest"
modified lib/git_gud/packages/signing.ex
+14 −0
@@ -124,6 +124,20 @@ defmodule GitGud.Packages.Signing do
124 124 def namespace(%Repository{} = repo), do: GitGud.Packages.namespace(repo)
125 125 def namespace(scope), do: scope
126 126
127 + @doc """
128 + The atom `:crypto` and `:public_key` know a digest by.
129 +
130 + They spell SHA-1 `:sha`, with no `1`. This behaviour's option is
131 + `:sha1`, because that is what the formats using it call it —
132 + F-Droid's index-v1 is `SHA1withRSA` — and because `:sha` reads like a
133 + typo next to `:sha256`. One translation here beats every call site
134 + remembering, and getting it wrong is a crash at signing time rather
135 + than a compile error.
136 + """
137 + @spec erlang_digest(:sha256 | :sha1) :: :sha256 | :sha
138 + def erlang_digest(:sha1), do: :sha
139 + def erlang_digest(other), do: other
140 +
127 141 @doc """
128 142 Whether `format` signs through something other than the local key
129 143 store. The packages UI says so on the page, because "this index is
modified lib/git_gud/packages/signing/local.ex
+2 −1
@@ -40,7 +40,8 @@ defmodule GitGud.Packages.Signing.Local do
40 40
41 41 with {:ok, %{private_pem: pem}} <- Packages.signing_key(scope, format),
42 42 {:ok, key} <- decode_private(pem) do
43 − {:ok, :public_key.sign(data, digest, key, sign_options(scheme))}
43 + erlang_digest = GitGud.Packages.Signing.erlang_digest(digest)
44 + {:ok, :public_key.sign(data, erlang_digest, key, sign_options(scheme))}
44 45 end
45 46 rescue
46 47 # `:public_key.sign/4` raises on a key it can't use — a caller
modified lib/git_gud/packages/usage.ex
+0 −1
@@ -399,7 +399,6 @@ defmodule GitGud.Packages.Usage do
399 399
400 400 def for_format("fdroid", ctx) do
401 401 url = base_url(ctx, "fdroid")
402 − namespace = namespace_url(ctx, "fdroid")
403 402
404 403 %{
405 404 consume: [
modified test/git_gud/packages/fdroid_format_test.exs
+48 −0
@@ -242,6 +242,54 @@ defmodule GitGud.Packages.Formats.FdroidTest do
242 242 end
243 243 end
244 244
245 + describe "index-v1" do
246 + setup %{repo: repo} do
247 + {:ok, _, _} = publish(repo, build_apk())
248 + :ok
249 + end
250 +
251 + test "is served, because a v2-only repository reads as broken", %{repo: repo} do
252 + # A client that can't speak v2 falls back here silently after
253 + # fetching a perfectly good entry.jar, and reports the 404 —
254 + # "invalid repository / NotFoundException", pointing at nothing.
255 + assert {:ok, "application/java-archive", jar} =
256 + Fdroid.index(repo, ctx(%{path: ["index-v1.jar"]}))
257 +
258 + {:ok, members} = :zip.unzip(IO.iodata_to_binary(jar), [:memory])
259 + names = members |> Enum.map(fn {n, _} -> List.to_string(n) end) |> MapSet.new()
260 +
261 + assert "index-v1.json" in names
262 + assert "META-INF/CERT.RSA" in names
263 + end
264 +
265 + test "carries the flat shape v1 expects", %{repo: repo} do
266 + index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))
267 +
268 + assert index["repo"]["version"] == 20_001
269 + assert index["repo"]["address"] == "https://forge.test/pkg/fdroid/acme/_all"
270 + # Read unconditionally by the client even when empty.
271 + assert index["requests"] == %{"install" => [], "uninstall" => []}
272 +
273 + assert [app] = index["apps"]
274 + assert app["packageName"] == "org.neiam.gitgud.app"
275 + assert app["suggestedVersionCode"] == "1"
276 +
277 + assert [pkg] = index["packages"]["org.neiam.gitgud.app"]
278 + assert pkg["apkName"] == "org.neiam.gitgud.app_1.apk"
279 + assert pkg["hashType"] == "sha256"
280 + assert pkg["versionCode"] == 1
281 + assert pkg["minSdkVersion"] == 26
282 + assert pkg["signer"] == :sha256 |> :crypto.hash(@cert) |> Base.encode16(case: :lower)
283 + end
284 +
285 + test "spells permissions as [name, maxSdk] pairs, not v2's objects", %{repo: repo} do
286 + index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))
287 + [pkg] = index["packages"]["org.neiam.gitgud.app"]
288 +
289 + assert ["android.permission.CAMERA", nil] in pkg["uses-permission"]
290 + end
291 + end
292 +
245 293 describe "serving APKs" do
246 294 test "hands back the stored file by the name the index points at", %{repo: repo} do
247 295 {:ok, _, _} = publish(repo, build_apk())

Parents: 9f00bdd