neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Serve index-v1, so older F-Droid clients can read the repository
4230fe5 · Gabriel Morell · 2026-09-12 18:35
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