Deb/RPM native metadata — design
Class:
design· Function:serve(the Deb + RPM repository backends). Sibling how-tos:deb-rpm-native-metadata-guide(serve a repo) anddeb-rpm-native-metadata-for-idiots(point-and-go). Whole-system view:design.md; serve path:serve-design.md.
The problem — a mirror of bare packages
Holger serves a Debian (APT) repo and an RPM (yum/dnf) repo straight out of an immutable .znippy archive. When the archive was built by the znippy deb_native / rpm_native plugin it carries a typed sub-index — a pre-parsed view of every package's control/header fields — and holger just reads that. But an archive is often a plain mirror of package bytes: someone dropped a folder of .deb / .rpm files in, no plugin, no sub-index. The client (apt, dnf) still demands a full index — APT Packages/Release, rpm-md repomd.xml/primary.xml — with real fields it can resolve dependencies against.
Previously holger filled the gap by guessing from the filename: it split {name}_{ver}_{arch}.deb / {name}-{ver}-{rel}.{arch}.rpm for coordinates and emitted nothing else — no Depends, no Maintainer, RPM epoch=0, no <format>. That is enough to list packages, not enough to resolve them. Both backends carried an honest RED maturity-disclosure row saying so.
This change closes the gap: holger now parses the real control stanza / RPM header out of the served package bytes, with no reliance on the znippy plugin. Filename-derivation drops to last-resort fallback and both maturity rows flip green.
Fallback order — three tiers, most-authoritative first
Both backends resolve each package's metadata in the same precedence:
| # | Source | Applies when |
| 1 | Typed sub-index (as_deb / as_rpm view) |
the archive was built with the znippy deb_native / rpm_native plugin |
| 2 | Native parse of the served bytes — new | mirrored / plugin-less archive; the package body itself is decoded |
| 3 | Filename derivation | tiers 1 & 2 both miss (unreadable/hostile package) — coordinates only |
There is a fourth, outer tier that wins over all of these for the index files themselves: authoritative path-passthrough. If the archive already carries a real Release/Packages or repomd.xml/primary.xml (a byte-exact upstream mirror), holger serves that verbatim and never synthesizes. Only when the index path misses does synthesis run — and synthesis is where the three tiers above pick each package's fields.
- Deb passthrough-vs-synthesize dispatch:
server/repository/znippy-deb/src/lib.rs:347(handle_http2_request) → passthroughget_fileat:364, thensynthesize_indexat:369/:275. - Deb per-package precedence (tiers 1→2→3):
server/repository/znippy-deb/src/lib.rs:194, thecontrols.get(...).or_else(|| control::control_from_deb(&bytes))chain. - RPM per-package precedence:
server/repository/znippy-rpm/src/lib.rs:169, themeta.get(...)→header::parse_rpm_header(...)match.
Deb — the ar + control.tar.* parser
A .deb is an ar(5) archive with exactly three members in order: debian-binary, control.tar.{,gz,xz,zst}, data.tar.{,gz,xz,zst}. Only the control tarball is needed, and it holds a single ./control file — the Debian control stanza (Depends / Maintainer / Description / …). Module: server/repository/znippy-deb/src/control.rs.
Pipeline (control_from_deb, control.rs:32):
- Hand-parse the
arcontainer (control_tar_member,:41). Thearformat is trivial — an 8-byte global magic (!<arch>\n,AR_MAGIC:20) then fixed 60-byte member headers (AR_HEADER:22): name in bytes0..16(space-padded, GNU may append/), decimal size in48..58, members padded to an even boundary. Noarcrate — walking it is a dozen lines. Every offset is bounds-checked; a truncated or lying header returnsNone, never panics. - Pick the codec from the member extension (
decompress_control_tar,:72) — a pure extension match, not content sniffing:control.tar→ verbatim,.gz→flate2::read::GzDecoder,.xz→liblzma::read::XzDecoder,.zst→zstd::stream::read::Decoder. Anything else →None. - Untar
./control(control_from_tar,:89) via thetarcrate; return its UTF-8 body.
The stanza is then normalized into Packages field lines by clean_control_stanza (lib.rs:308), and holger appends its own repo-level Filename / Size / SHA256 (computed over the real served bytes) on top — the three fields APT verifies a [trusted=yes] download against.
RPM — the binary header tag-table parser
An .rpm is a 96-byte lead, a signature header, then the main header. Both headers share the RPMv3 structure: magic 8e ad e8 01, 4 reserved bytes, a big-endian nindex (entry count) and hsize (data-store length), then nindex 16-byte index entries (tag, type, offset, count), then the hsize-byte store the offsets point into. Module: server/repository/znippy-rpm/src/header.rs.
Pipeline (parse_rpm_header, header.rs:174):
- Skip the lead — validate the 4-byte lead magic (
LEAD_MAGIC:22), step over 96 bytes (LEAD_LEN:21). - Step over the signature header without fully decoding it (
header_end,:64— reads itsnindex/hsize, returns the byte just past its store), then align the main header to an 8-byte boundary:(sig_end + 7) & !7(lib.rs/:186). - Decode the main header's tag table (
parse_header,:84) intoEntryrecords, each validated so itsoffsetlands inside the store. - Read the wanted tags by number into an
RpmMetaRow(znippy_common::views):EPOCH(1003, int32),SUMMARY(1004),VENDOR(1011),LICENSE(1014),URL(1020),SOURCERPM(1044),PROVIDENAME(1047, string-array),REQUIRENAME(1049, string-array). Tag constants:header.rs:27. Strings viaread_string(:118), arrays viaread_string_array(:125).
primary.xml synthesis then emits epoch="…", <summary>, <url>, and the <format> block (<rpm:license>, <rpm:provides>/<rpm:requires> as <rpm:entry> rows) from that row — server/repository/znippy-rpm/src/lib.rs:169 onward. repomd.xml references primary.xml by SHA256+size; the index is served uncompressed so dnf verifies it directly.
Bounds, bomb-caps, and hostile input
Both parsers treat the package as adversarial — a mirrored archive may contain anything. Rules held throughout:
- No
unsafe, no panics. Every slice isget(..)-checked or width-validated; malformed bytes yieldNone, and the caller silently drops to the next fallback tier. Proven by thenon_*_bytes_yield_none/truncated_store_yields_noneunit tests in each module. - Decompression-bomb cap (Deb): the control tarball is read through
take(MAX_CONTROL_TAR)— 16 MiB (control.rs:25). A realcontrolis a few KiB; a 4-byte.zstthat inflates to gigabytes is truncated, not honoured. - Resource caps (RPM):
MAX_NINDEX= 65 536 tags,MAX_STORE= 64 MiB (header.rs:45). A header claiming billions of entries is rejected before any allocation. - Every arithmetic on offsets is
checked_*so a crafted size can't wrap and read out of bounds.
Both modules ship a synthetic-fixture encoder in a tests_support submodule (a real minimal .deb / byte-exact .rpm) so the crate's lib.rs integration tests drive the whole synthesis path — native_control_parse_enriches_packages and native_header_parse_enriches_primary prove the emitted index carries the parsed fields with no typed view present.
Codec dep note — one workspace-wide links = "lzma"
Rule for future contributors: use liblzma, never xz2. Both crates wrap the same native lzma library and both declare links = "lzma" in their -sys crate (xz2 → lzma-sys; liblzma → liblzma-sys). Cargo permits only one crate with a given links value in a dependency graph, so a workspace that pulls both fails to resolve — a hard error, not a warning.
DataFusion (reached via skade / knut / nornir) already pulls liblzma-sys. holger-znippy-deb therefore decodes control.tar.xz with liblzma (control.rs:81), and the two zip dev-deps that defaulted to an xz feature now set default-features = false, features = ["deflate", "time"] (they only ever write Deflated entries):
server/repository/znippy-deb/Cargo.toml—liblzma = "0.4"(wasxz2), with the rationale in a comment beside it.bench-scenarios/Cargo.toml,server/repository/znippy-rust/Cargo.toml—zipwithdefault-features = false.
Reintroducing xz2 (or any crate that transitively adds lzma-sys) re-breaks workspace resolution for every consumer that also sees DataFusion. If you need xz, reach for liblzma.