5.9 KiB · text History b868907

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:

  • revision and every timestamp is the newest publish in the repository, not now. 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.

Sources