RPM repositories
pkg/rpm/<owner>/<repo> and pkg/rpm/<owner>/_all, consumed by dnf,
zypper and anything else that speaks rpm-md.
This is the apt adapter’s shape one registry over, and it was scoped as such: everything expensive was already built. What follows is what actually shipped, and the decisions worth not relitigating.
What was already there
| Need | Reused |
|---|---|
| detached OpenPGP signature over the index | Packages.Apt.Native.sign_detached/2 — the same call Release.gpg makes |
| a per-namespace signing key | SigningKey, via Packages.signing_key/2 |
| serving the public key before any credential exists | /pkg/_keys/rpm/:owner |
_all across a namespace |
Format.namespace_index/3 |
| auth, storage, retention, pruning, the packages UI | unchanged |
The genuinely new code is an RPM header parser, the three rpm-md XML
documents, and repomd.xml.
The header parser
GitGud.Packages.Formats.Rpm.Header. An RPM is a 96-byte lead, a
signature header, padding to an 8-byte boundary, the main header, and a
compressed cpio payload. Both headers are the same structure: a magic,
an entry count, a data-store size, then {tag, type, offset, count}
index entries pointing into the store.
Parsed in Elixir rather than shelled out to rpm, for the same reason
Deb.Control parses ar itself — the runtime image has no rpm tooling
and adding some is a deployment change wearing a dependency’s clothes.
The payload is never read. primary.xml and filelists.xml are
built entirely from header tags, including the file list, which RPM
stores as DIRNAMES / DIRINDEXES / BASENAMES precisely so that a
repository can be indexed without decompressing anything. A 200 MB
package is parsed by reading its first few hundred kilobytes.
Two tag tables, not one. The signature header numbers its tags in
the same low range as the main header and means different things by
them: 1007 is the payload size there and the build host here. One
shared table silently reads a size as a hostname, which is how
archive= in primary.xml ends up holding "reproducible".
Verified against aspell-sl-0.50-4.6.x86_64.rpm from Tumbleweed, with
every expected value taken from rpm -qp --qf rather than from our own
output. The fixture in test/support/fixtures/rpm/ is that package
truncated to its headers — 9141 bytes of a 5.9 MB file, cut exactly
where the parser should stop.
repomd.xml must agree with itself
repomd.xml pins a checksum, a size and a timestamp for each of the
three documents, and dnf rejects a repository where they don’t match
what it downloads. The index is generated per request — it has to be,
because the caller’s visibility is part of the answer — so nothing in it
may depend on the clock:
revisionand everytimestampis the newest publish in the repository, notnow. Zero for an empty one.- gzip is
:zlib.gzip/1, which writes a zero mtime.
This is the same mistake that sank the first F-Droid index, which pinned a hash it then regenerated.
Decisions
The forge signs the index, not the packages. repo_gpgcheck=1
verifies repomd.xml.asc against the namespace’s key. gpgcheck=1
verifies a signature baked into each .rpm at build time, by whoever
built it — a different job, and one the forge can’t do on a package it
only received. CI that publishes unsigned RPMs wants gpgcheck=0.
deb and rpm get separate keys. Both are OpenPGP and the same NIF generates them, but the formats are configured independently and a namespace that stops publishing one should be able to retire its key without touching the other.
Package paths are qualified by repository. Packages/<repo>/<p>/…
rather than createrepo’s Packages/<p>/…, because _all aggregates
several repositories and two of them publishing
koco-0.1.2-1.x86_64.rpm would otherwise be one path with two sets of
bytes. location href is opaque to dnf, and qualifying it in both views
means a client switching between them re-downloads nothing.
createrepo’s file filter, unchanged. Files under bin/, under
/etc, and sendmail go in primary.xml; the rest wait in
filelists.xml. Diverging would make our repository resolve
differently from a stock one for the same packages, which is worse than
a large index.
other.xml is emitted empty. Changelogs are in the header and
nothing reads them yet, but dnf expects the document and a repository
without it looks corrupt rather than sparse.
Verification
Beyond the test suite: the generated tree was pointed at with real
zypper, which built its cache, resolved aspell-sl at the right
version and architecture, and downloaded it — checksum from
primary.xml verified. zypper’s metadata parser is libsolv, which is
the same one dnf uses.
Flatpak, and why it isn’t here
A Flatpak repository is an OSTree repo: archive-z2 objects, a summary
file encoded in GVariant, GPG signatures on every commit. That is not a
format adapter, it is OSTree’s object model plus a GVariant serializer.
A plain static HTTP server is sufficient to serve one, so all the
leverage is on the producing side. If it becomes worth doing, the cheap
shape is: CI runs flatpak build-update-repo and uploads the tree; the
forge stores it, serves it with auth, and generates the .flatpakrepo
— the same delegation the OCI registry makes to zot. The substrate gap
that would need closing first is that Packages.publish_file/5 is one
file per call with a filename, and a Flatpak repo is an opaque tree.
Single-file .flatpak bundles (flatpak build-bundle) need none of
that and can be served as generic artifacts today, at the cost of
flatpak update.