Serve rpm-md repositories, so dnf and zypper can install from the forge

ff5c190 · Gabriel Morell · 2026-09-14 03:58

33 files +2212 -177
Message
{commit_body(@commit)}

Files changed

added docs/rpm-registry.md
+129 −0
@@ -0,0 +1,129 @@
1 +# RPM repositories
2 +
3 +`pkg/rpm/<owner>/<repo>` and `pkg/rpm/<owner>/_all`, consumed by dnf,
4 +zypper and anything else that speaks rpm-md.
5 +
6 +This is the apt adapter's shape one registry over, and it was scoped as
7 +such: everything expensive was already built. What follows is what
8 +actually shipped, and the decisions worth not relitigating.
9 +
10 +## What was already there
11 +
12 +| Need | Reused |
13 +| --- | --- |
14 +| detached OpenPGP signature over the index | `Packages.Apt.Native.sign_detached/2` — the same call `Release.gpg` makes |
15 +| a per-namespace signing key | `SigningKey`, via `Packages.signing_key/2` |
16 +| serving the public key before any credential exists | `/pkg/_keys/rpm/:owner` |
17 +| `_all` across a namespace | `Format.namespace_index/3` |
18 +| auth, storage, retention, pruning, the packages UI | unchanged |
19 +
20 +The genuinely new code is an RPM header parser, the three rpm-md XML
21 +documents, and `repomd.xml`.
22 +
23 +## The header parser
24 +
25 +`GitGud.Packages.Formats.Rpm.Header`. An RPM is a 96-byte lead, a
26 +signature header, padding to an 8-byte boundary, the main header, and a
27 +compressed cpio payload. Both headers are the same structure: a magic,
28 +an entry count, a data-store size, then `{tag, type, offset, count}`
29 +index entries pointing into the store.
30 +
31 +Parsed in Elixir rather than shelled out to `rpm`, for the same reason
32 +`Deb.Control` parses `ar` itself — the runtime image has no rpm tooling
33 +and adding some is a deployment change wearing a dependency's clothes.
34 +
35 +**The payload is never read.** `primary.xml` and `filelists.xml` are
36 +built entirely from header tags, including the file list, which RPM
37 +stores as `DIRNAMES` / `DIRINDEXES` / `BASENAMES` precisely so that a
38 +repository can be indexed without decompressing anything. A 200 MB
39 +package is parsed by reading its first few hundred kilobytes.
40 +
41 +**Two tag tables, not one.** The signature header numbers its tags in
42 +the same low range as the main header and means different things by
43 +them: 1007 is the payload size there and the build host here. One
44 +shared table silently reads a size as a hostname, which is how
45 +`archive=` in `primary.xml` ends up holding `"reproducible"`.
46 +
47 +Verified against `aspell-sl-0.50-4.6.x86_64.rpm` from Tumbleweed, with
48 +every expected value taken from `rpm -qp --qf` rather than from our own
49 +output. The fixture in `test/support/fixtures/rpm/` is that package
50 +truncated to its headers — 9141 bytes of a 5.9 MB file, cut exactly
51 +where the parser should stop.
52 +
53 +## repomd.xml must agree with itself
54 +
55 +`repomd.xml` pins a checksum, a size and a timestamp for each of the
56 +three documents, and dnf rejects a repository where they don't match
57 +what it downloads. The index is generated per request — it has to be,
58 +because the caller's visibility is part of the answer — so nothing in it
59 +may depend on the clock:
60 +
61 +- `revision` and every `timestamp` is the newest publish in the
62 + repository, not `now`. Zero for an empty one.
63 +- gzip is `:zlib.gzip/1`, which writes a zero mtime.
64 +
65 +This is the same mistake that sank the first F-Droid index, which
66 +pinned a hash it then regenerated.
67 +
68 +## Decisions
69 +
70 +**The forge signs the index, not the packages.** `repo_gpgcheck=1`
71 +verifies `repomd.xml.asc` against the namespace's key. `gpgcheck=1`
72 +verifies a signature baked into each `.rpm` at build time, by whoever
73 +built it — a different job, and one the forge can't do on a package it
74 +only received. CI that publishes unsigned RPMs wants `gpgcheck=0`.
75 +
76 +**deb and rpm get separate keys.** Both are OpenPGP and the same NIF
77 +generates them, but the formats are configured independently and a
78 +namespace that stops publishing one should be able to retire its key
79 +without touching the other.
80 +
81 +**Package paths are qualified by repository.** `Packages/<repo>/<p>/…`
82 +rather than createrepo's `Packages/<p>/…`, because `_all` aggregates
83 +several repositories and two of them publishing
84 +`koco-0.1.2-1.x86_64.rpm` would otherwise be one path with two sets of
85 +bytes. `location href` is opaque to dnf, and qualifying it in both views
86 +means a client switching between them re-downloads nothing.
87 +
88 +**createrepo's file filter, unchanged.** Files under `bin/`, under
89 +`/etc`, and sendmail go in `primary.xml`; the rest wait in
90 +`filelists.xml`. Diverging would make our repository resolve
91 +differently from a stock one for the same packages, which is worse than
92 +a large index.
93 +
94 +**`other.xml` is emitted empty.** Changelogs are in the header and
95 +nothing reads them yet, but dnf expects the document and a repository
96 +without it looks corrupt rather than sparse.
97 +
98 +## Verification
99 +
100 +Beyond the test suite: the generated tree was pointed at with real
101 +`zypper`, which built its cache, resolved `aspell-sl` at the right
102 +version and architecture, and downloaded it — checksum from
103 +`primary.xml` verified. zypper's metadata parser is libsolv, which is
104 +the same one dnf uses.
105 +
106 +## Flatpak, and why it isn't here
107 +
108 +A Flatpak repository is an OSTree repo: archive-z2 objects, a `summary`
109 +file encoded in GVariant, GPG signatures on every commit. That is not a
110 +format adapter, it is OSTree's object model plus a GVariant serializer.
111 +
112 +A plain static HTTP server is sufficient to *serve* one, so all the
113 +leverage is on the producing side. If it becomes worth doing, the cheap
114 +shape is: CI runs `flatpak build-update-repo` and uploads the tree; the
115 +forge stores it, serves it with auth, and generates the `.flatpakrepo`
116 +— the same delegation the OCI registry makes to zot. The substrate gap
117 +that would need closing first is that `Packages.publish_file/5` is one
118 +file per call with a filename, and a Flatpak repo is an opaque tree.
119 +
120 +Single-file `.flatpak` bundles (`flatpak build-bundle`) need none of
121 +that and can be served as generic artifacts today, at the cost of
122 +`flatpak update`.
123 +
124 +## Sources
125 +
126 +- [rpm-md metadata](https://en.opensuse.org/openSUSE:Standards_Rpm_Metadata)
127 +- [rpm-metadata](https://github.com/rpm-software-management/rpm-metadata)
128 +- [rpm's `rpmtag.h`](https://github.com/rpm-software-management/rpm/blob/master/include/rpm/rpmtag.h)
129 +- [Flatpak: hosting a repository](https://docs.flatpak.org/en/latest/hosting-a-repository.html)
modified lib/git_gud/packages.ex
+9 −4
@@ -547,10 +547,15 @@ defmodule GitGud.Packages do
547 547 end
548 548 end
549 549
550 − # apt needs an OpenPGP certificate, which OTP cannot produce — see
551 − # `GitGud.Packages.Apt.Native`. Everything else wants RSA in PEM,
552 − # which is the machinery the OCI token signer already has.
553 − defp generate_keypair(owner, "deb") do
550 + # apt and dnf both need an OpenPGP certificate, which OTP cannot
551 + # produce — see `GitGud.Packages.Apt.Native`. They get separate keys
552 + # rather than one shared one: the formats are configured
553 + # independently, and a namespace that stops publishing one should be
554 + # able to retire its key without touching the other.
555 + #
556 + # Everything else wants RSA in PEM, which is the machinery the OCI
557 + # token signer already has.
558 + defp generate_keypair(owner, format) when format in ~w(deb rpm) do
554 559 handle = GitGud.Repositories.Storage.owner_handle(owner)
555 560
556 561 case GitGud.Packages.Apt.Native.generate_key("#{handle} (GitGud package registry)") do
modified lib/git_gud/packages/format.ex
+2 −1
@@ -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 fdroid)
41 + @formats ~w(oci pypi hex cargo deb rpm 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
@@ -50,6 +50,7 @@ defmodule GitGud.Packages.Format do
50 50 "hex" => GitGud.Packages.Formats.Hex,
51 51 "cargo" => GitGud.Packages.Formats.Cargo,
52 52 "deb" => GitGud.Packages.Formats.Deb,
53 + "rpm" => GitGud.Packages.Formats.Rpm,
53 54 "fdroid" => GitGud.Packages.Formats.Fdroid
54 55 }
55 56
modified lib/git_gud/packages/formats/deb.ex
+8 −170
@@ -70,6 +70,7 @@ defmodule GitGud.Packages.Formats.Deb do
70 70 alias GitGud.Packages
71 71 alias GitGud.Packages.Apt.Native
72 72 alias GitGud.Packages.Formats.Deb.Control
73 + alias GitGud.Packages.Formats.Listing
73 74 alias GitGud.Packages.PackageFile
74 75 alias GitGud.Repositories.Repository
75 76 alias GitGud.Repositories.Storage
@@ -174,27 +175,13 @@ defmodule GitGud.Packages.Formats.Deb do
174 175
175 176 # ── browsing ────────────────────────────────────────────────────────
176 177
177 − # A Debian mirror is a tree of files over plain HTTP, and every one of
178 − # them is conventionally browsable — `deb.debian.org/debian/` is an
179 − # autoindex you can click down through. Ours only ever answered exact
180 − # paths, so pointing a browser at a repository got a 404 and there was
181 − # no way to see what was published without already knowing its name.
182 − #
183 − # apt never requests a directory, so none of this is on its path; it
184 − # exists for the person checking whether their upload landed.
185 − #
186 − # The listing is derived from the paths this repository actually
187 − # serves, never from the filesystem or from a separate enumeration —
188 − # so it cannot advertise something that would 404, and it cannot leak
189 − # a repository the caller couldn't already read, because `repos` was
190 − # filtered before it got here.
191 − defp listing(repos, label, %{path: prefix} = ctx) do
192 − case children(served_paths(repos), prefix) do
193 − # An empty listing below the root is a path that doesn't exist,
194 − # not an empty directory — the tree has no empty nodes in it.
195 − [] when prefix != [] -> {:error, :not_found}
196 − entries -> {:ok, "text/html; charset=utf-8", index_page(label, ctx, prefix, entries)}
197 − end
178 + defp listing(repos, label, ctx) do
179 + Listing.serve(
180 + served_paths(repos),
181 + label,
182 + "Debian repository · apt reads this tree; you can browse it.",
183 + ctx
184 + )
198 185 end
199 186
200 187 # Every path this repository answers to, with the size and date where
@@ -225,155 +212,6 @@ defmodule GitGud.Packages.Formats.Deb do
225 212 Enum.map(debs, &%{path: pool_path(&1), size: &1.file.size, at: &1.file.inserted_at})
226 213 end
227 214
228 − # The immediate children of `prefix`: a path with exactly one segment
229 − # left is a file, more than one means a directory we've now seen.
230 − defp children(paths, prefix) do
231 − depth = length(prefix)
232 −
233 − paths
234 − |> Enum.flat_map(fn entry ->
235 − segments = String.split(entry.path, "/")
236 −
237 − if List.starts_with?(segments, prefix) do
238 − case Enum.drop(segments, depth) do
239 − [name] -> [%{name: name, dir?: false, size: entry.size, at: entry.at}]
240 − [name | _] -> [%{name: name, dir?: true, size: nil, at: nil}]
241 − [] -> []
242 − end
243 − else
244 − []
245 − end
246 − end)
247 − |> Enum.uniq_by(& &1.name)
248 − |> Enum.sort_by(&{not &1.dir?, &1.name})
249 − end
250 −
251 − # A plain document rather than the app layout — this is served from
252 − # the registry URL, which a browser may well reach with a Basic-auth
253 − # prompt and no session at all — but it still wears the forge's
254 − # colours.
255 − #
256 − # It pulls in `app.css` for the daisyUI theme definitions and repeats
257 − # the root layout's theme bootstrap, so `data-theme` is set before
258 − # first paint and the page matches whatever the viewer picked. Styling
259 − # then goes through the theme's own custom properties rather than
260 − # Tailwind utility classes: this module lives under `lib/git_gud`, and
261 − # `app.css` only scans `lib/git_gud_web`, so utility classes written
262 − # here would be purged out of the bundle and silently do nothing.
263 − #
264 − # The bootstrap is read-only — no picker on this page, so it never
265 − # writes `phx:theme` — but it does listen for `storage`, so changing
266 − # the theme in another tab recolours this one.
267 − defp index_page(label, ctx, prefix, entries) do
268 − here = Enum.join([ctx.base_url | prefix], "/")
269 −
270 − rows =
271 − Enum.map(entries, fn entry ->
272 − href = here <> "/" <> URI.encode(entry.name) <> if(entry.dir?, do: "/", else: "")
273 −
274 − [
275 − "<tr><td><a class=\"",
276 − if(entry.dir?, do: "dir", else: "file"),
277 − "\" href=\"",
278 − esc(href),
279 − "\">",
280 − esc(entry.name),
281 − if(entry.dir?, do: "/", else: ""),
282 − "</a></td><td class=\"n\">",
283 − format_size(entry.size),
284 − "</td><td class=\"n\">",
285 − format_date(entry.at),
286 − "</td></tr>\n"
287 − ]
288 − end)
289 −
290 − parent =
291 − if prefix == [] do
292 − []
293 − else
294 − up = Enum.join([ctx.base_url | Enum.drop(prefix, -1)], "/") <> "/"
295 − ["<tr><td><a class=\"dir\" href=\"", esc(up), "\">../</a></td><td></td><td></td></tr>\n"]
296 − end
297 −
298 − title = "/" <> Enum.join(prefix, "/")
299 −
300 − [
301 − """
302 − <!DOCTYPE html>
303 − <html lang="en"><head><meta charset="utf-8">
304 − <meta name="viewport" content="width=device-width, initial-scale=1">
305 − <title>#{esc(label)} #{esc(title)}</title>
306 − <link rel="stylesheet" href="#{esc(stylesheet_path())}">
307 − <script>
308 − (() => {
309 − const SYSTEM_DARK = "#{GitGud.Themes.system_dark()}";
310 − const SYSTEM_LIGHT = "#{GitGud.Themes.system_light()}";
311 − const mq = window.matchMedia("(prefers-color-scheme: dark)");
312 − const systemTheme = () => mq.matches ? SYSTEM_DARK : SYSTEM_LIGHT;
313 − const apply = (theme) =>
314 − document.documentElement.setAttribute("data-theme", theme || systemTheme());
315 − apply(localStorage.getItem("phx:theme"));
316 − mq.addEventListener("change", () => {
317 − if (!localStorage.getItem("phx:theme")) apply(null);
318 − });
319 − window.addEventListener("storage", (e) => {
320 − if (e.key === "phx:theme") apply(e.newValue);
321 − });
322 − })();
323 − </script>
324 − <style>
325 − body {
326 − background: var(--color-base-100);
327 − color: var(--color-base-content);
328 − font-family: ui-monospace, "B612 Mono", SFMono-Regular, Menlo, monospace;
329 − font-size: 14px;
330 − line-height: 1.6;
331 − margin: 0 auto;
332 − max-width: 60rem;
333 − min-height: 100vh;
334 − padding: 2rem 1rem;
335 − }
336 − h1 { font-size: 1rem; font-weight: 600; margin: 0 0 .25rem; }
337 − h1 .path { opacity: .55; }
338 − p.sub { margin: 0 0 1.5rem; opacity: .6; font-size: .8rem; }
339 − table { border-collapse: collapse; width: 100%; }
340 − td { padding: .25rem .75rem .25rem 0; border-bottom: 1px solid var(--color-base-300); }
341 − td.n { text-align: right; white-space: nowrap; opacity: .6; font-size: .8rem; }
342 − a { color: inherit; text-decoration: none; }
343 − a:hover { text-decoration: underline; }
344 − a.dir { color: var(--color-primary); }
345 − </style></head><body>
346 − <h1>#{esc(label)}<span class="path">#{esc(title)}</span></h1>
347 − <p class="sub">Debian repository · apt reads this tree; you can browse it.</p>
348 − <table>
349 − """,
350 − parent,
351 − rows,
352 − "</table></body></html>\n"
353 − ]
354 − end
355 −
356 − # Digest-aware, so the stylesheet resolves in a release the same way
357 − # the app layout's does.
358 − defp stylesheet_path, do: GitGudWeb.Endpoint.static_path("/assets/css/app.css")
359 −
360 − defp format_size(nil), do: ""
361 − defp format_size(bytes) when bytes < 1024, do: "#{bytes} B"
362 − defp format_size(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
363 − defp format_size(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
364 −
365 − defp format_date(nil), do: ""
366 − defp format_date(at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M")
367 −
368 − defp esc(value) do
369 − value
370 − |> to_string()
371 − |> String.replace("&", "&amp;")
372 − |> String.replace("<", "&lt;")
373 − |> String.replace(">", "&gt;")
374 − |> String.replace("\"", "&quot;")
375 − end
376 −
377 215 # ── the indices ─────────────────────────────────────────────────────
378 216
379 217 # Every .deb across `repos`, flattened with the fields the indices
added lib/git_gud/packages/formats/listing.ex
+194 −0
@@ -0,0 +1,194 @@
1 +defmodule GitGud.Packages.Formats.Listing do
2 + @moduledoc """
3 + A browsable directory index for the registries that are plain file
4 + trees over HTTP.
5 +
6 + apt and dnf both consume a mirror by fetching exact paths, and both
7 + are conventionally *browsable* as well — `deb.debian.org/debian/` and
8 + `download.fedoraproject.org/.../os/` are autoindexes you can click
9 + down through. Ours only ever answered exact paths, so pointing a
10 + browser at a repository got a 404 and there was no way to see whether
11 + an upload landed without already knowing its name.
12 +
13 + No client is on this path; it exists for the person checking.
14 +
15 + The listing is derived from the paths the adapter says it serves,
16 + never from the filesystem or a separate enumeration — so it cannot
17 + advertise something that would 404, and it cannot leak a repository
18 + the caller couldn't already read, because the repository list was
19 + filtered before the adapter ever saw it.
20 + """
21 +
22 + @typedoc """
23 + One path the adapter answers to. `size` and `at` are optional, and
24 + are `nil` for generated index documents: sizing those would mean
25 + building all of them to draw a table.
26 + """
27 + @type entry :: %{path: String.t(), size: non_neg_integer() | nil, at: DateTime.t() | nil}
28 +
29 + @doc """
30 + Render the directory at `ctx.path`, given every path the repository
31 + serves.
32 +
33 + Returns `{:error, :not_found}` for an empty listing below the root —
34 + a path with no children is a path that doesn't exist, since the tree
35 + has no empty nodes in it.
36 + """
37 + @spec serve([entry()], String.t(), String.t(), map()) ::
38 + {:ok, String.t(), iodata()} | {:error, :not_found}
39 + def serve(paths, label, subtitle, %{path: prefix} = ctx) do
40 + case children(paths, prefix) do
41 + [] when prefix != [] -> {:error, :not_found}
42 + entries -> {:ok, "text/html; charset=utf-8", page(label, subtitle, ctx, prefix, entries)}
43 + end
44 + end
45 +
46 + # The immediate children of `prefix`: a path with exactly one segment
47 + # left is a file, more than one means a directory we've now seen.
48 + defp children(paths, prefix) do
49 + depth = length(prefix)
50 +
51 + paths
52 + |> Enum.flat_map(fn entry ->
53 + segments = String.split(entry.path, "/")
54 +
55 + if List.starts_with?(segments, prefix) do
56 + case Enum.drop(segments, depth) do
57 + [name] -> [%{name: name, dir?: false, size: entry.size, at: entry.at}]
58 + [name | _] -> [%{name: name, dir?: true, size: nil, at: nil}]
59 + [] -> []
60 + end
61 + else
62 + []
63 + end
64 + end)
65 + |> Enum.uniq_by(& &1.name)
66 + |> Enum.sort_by(&{not &1.dir?, &1.name})
67 + end
68 +
69 + # A plain document rather than the app layout — this is served from
70 + # the registry URL, which a browser may well reach with a Basic-auth
71 + # prompt and no session at all — but it still wears the forge's
72 + # colours.
73 + #
74 + # It pulls in `app.css` for the daisyUI theme definitions and repeats
75 + # the root layout's theme bootstrap, so `data-theme` is set before
76 + # first paint and the page matches whatever the viewer picked. Styling
77 + # then goes through the theme's own custom properties rather than
78 + # Tailwind utility classes: this module lives under `lib/git_gud`, and
79 + # `app.css` only scans `lib/git_gud_web`, so utility classes written
80 + # here would be purged out of the bundle and silently do nothing.
81 + #
82 + # The bootstrap is read-only — no picker on this page, so it never
83 + # writes `phx:theme` — but it does listen for `storage`, so changing
84 + # the theme in another tab recolours this one.
85 + defp page(label, subtitle, ctx, prefix, entries) do
86 + here = Enum.join([ctx.base_url | prefix], "/")
87 +
88 + rows =
89 + Enum.map(entries, fn entry ->
90 + href = here <> "/" <> URI.encode(entry.name) <> if(entry.dir?, do: "/", else: "")
91 +
92 + [
93 + "<tr><td><a class=\"",
94 + if(entry.dir?, do: "dir", else: "file"),
95 + "\" href=\"",
96 + esc(href),
97 + "\">",
98 + esc(entry.name),
99 + if(entry.dir?, do: "/", else: ""),
100 + "</a></td><td class=\"n\">",
101 + format_size(entry.size),
102 + "</td><td class=\"n\">",
103 + format_date(entry.at),
104 + "</td></tr>\n"
105 + ]
106 + end)
107 +
108 + parent =
109 + if prefix == [] do
110 + []
111 + else
112 + up = Enum.join([ctx.base_url | Enum.drop(prefix, -1)], "/") <> "/"
113 + ["<tr><td><a class=\"dir\" href=\"", esc(up), "\">../</a></td><td></td><td></td></tr>\n"]
114 + end
115 +
116 + title = "/" <> Enum.join(prefix, "/")
117 +
118 + [
119 + """
120 + <!DOCTYPE html>
121 + <html lang="en"><head><meta charset="utf-8">
122 + <meta name="viewport" content="width=device-width, initial-scale=1">
123 + <title>#{esc(label)} #{esc(title)}</title>
124 + <link rel="stylesheet" href="#{esc(stylesheet_path())}">
125 + <script>
126 + (() => {
127 + const SYSTEM_DARK = "#{GitGud.Themes.system_dark()}";
128 + const SYSTEM_LIGHT = "#{GitGud.Themes.system_light()}";
129 + const mq = window.matchMedia("(prefers-color-scheme: dark)");
130 + const systemTheme = () => mq.matches ? SYSTEM_DARK : SYSTEM_LIGHT;
131 + const apply = (theme) =>
132 + document.documentElement.setAttribute("data-theme", theme || systemTheme());
133 + apply(localStorage.getItem("phx:theme"));
134 + mq.addEventListener("change", () => {
135 + if (!localStorage.getItem("phx:theme")) apply(null);
136 + });
137 + window.addEventListener("storage", (e) => {
138 + if (e.key === "phx:theme") apply(e.newValue);
139 + });
140 + })();
141 + </script>
142 + <style>
143 + body {
144 + background: var(--color-base-100);
145 + color: var(--color-base-content);
146 + font-family: ui-monospace, "B612 Mono", SFMono-Regular, Menlo, monospace;
147 + font-size: 14px;
148 + line-height: 1.6;
149 + margin: 0 auto;
150 + max-width: 60rem;
151 + min-height: 100vh;
152 + padding: 2rem 1rem;
153 + }
154 + h1 { font-size: 1rem; font-weight: 600; margin: 0 0 .25rem; }
155 + h1 .path { opacity: .55; }
156 + p.sub { margin: 0 0 1.5rem; opacity: .6; font-size: .8rem; }
157 + table { border-collapse: collapse; width: 100%; }
158 + td { padding: .25rem .75rem .25rem 0; border-bottom: 1px solid var(--color-base-300); }
159 + td.n { text-align: right; white-space: nowrap; opacity: .6; font-size: .8rem; }
160 + a { color: inherit; text-decoration: none; }
161 + a:hover { text-decoration: underline; }
162 + a.dir { color: var(--color-primary); }
163 + </style></head><body>
164 + <h1>#{esc(label)}<span class="path">#{esc(title)}</span></h1>
165 + <p class="sub">#{esc(subtitle)}</p>
166 + <table>
167 + """,
168 + parent,
169 + rows,
170 + "</table></body></html>\n"
171 + ]
172 + end
173 +
174 + # Digest-aware, so the stylesheet resolves in a release the same way
175 + # the app layout's does.
176 + defp stylesheet_path, do: GitGudWeb.Endpoint.static_path("/assets/css/app.css")
177 +
178 + defp format_size(nil), do: ""
179 + defp format_size(bytes) when bytes < 1024, do: "#{bytes} B"
180 + defp format_size(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
181 + defp format_size(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
182 +
183 + defp format_date(nil), do: ""
184 + defp format_date(at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M")
185 +
186 + defp esc(value) do
187 + value
188 + |> to_string()
189 + |> String.replace("&", "&amp;")
190 + |> String.replace("<", "&lt;")
191 + |> String.replace(">", "&gt;")
192 + |> String.replace("\"", "&quot;")
193 + end
194 +end
added lib/git_gud/packages/formats/rpm.ex
+347 −0

Click to load diff…

added lib/git_gud/packages/formats/rpm/header.ex
+295 −0

Click to load diff…

added lib/git_gud/packages/formats/rpm/metadata.ex
+345 −0

Click to load diff…

modified lib/git_gud/packages/usage.ex
+102 −0

Click to load diff…

modified lib/git_gud_web/controllers/package_registry_controller.ex
+3 −1

Click to load diff…

modified lib/git_gud_web/live/repo_live/packages.ex
+6 −1

Click to load diff…

added test/git_gud/packages/rpm_format_test.exs
+288 −0

Click to load diff…

added test/git_gud/packages/rpm_header_test.exs
+140 −0

Click to load diff…

added test/git_gud_web/rpm_namespace_test.exs
+241 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added test/support/fixtures/rpm_fixtures.ex
+103 −0

Click to load diff…

Parents: da63596