server/repository/znippy-rpm/src/lib.rs
//! znippy-backed RPM (yum/dnf) repository backend — serves an rpm-md index
//! synthesized from the `.rpm` files inside a read-only znippy archive, the rpm
//! sibling of the deb/npm/maven `znippy-*` backends behind `RepositoryBackendTrait`.
//!
//! Two-stage `handle_http2_request`: authoritative path-passthrough first (packages,
//! and any real createrepo `repodata/` the archive already carries wins), then native
//! synthesis of `repodata/repomd.xml` + `repodata/primary.xml` from the packages
//! present. `primary.xml` lists one `<package>` per rpm with a real SHA256 + size over
//! the served bytes; `repomd.xml` references it by hash so dnf can resolve and verify.
//! NEVRA is parsed from the filename (`{name}-{version}-{release}.{arch}.rpm`), enriched
//! with epoch + `<format>` from the znippy `rpm_native` header view (`as_rpm`) when the
//! archive carries the sub-index; otherwise holger parses the RPM header NATIVELY out of
//! the served bytes (`header` module — real epoch/license/vendor/url/summary/sourcerpm/
//! provides/requires), and only when THAT misses falls back to filename-only `epoch=0`.
//!
//! Gotcha: synthesized indexes are cached because hashing the whole `Packages/` set per
//! request is a DoS-amplification vector — the cache is sound only because the archive is
//! immutable. And `fetch` matches on the *parsed* NEVRA (exact name + version), never a
//! string prefix, so `zlib 1.3.1` can't collide with `zlib-1.3.10`.
mod header;
/// The parser of untrusted package bytes, public for holger's fuzz targets (`fuzz/`).
pub use header::parse_rpm_header;
/// The test builders (feature `fixtures`): the fuzz seeds are made with them.
#[cfg(feature = "fixtures")]
pub use header::tests_support as fixtures;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::views::RpmMetaRow;
use znippy_common::ZnippyReader;
/// znippy-backed RPM (yum/dnf) repository.
///
/// dnf fetches the repodata index then the packages:
/// * `GET /{repo}/repodata/repomd.xml` → index of metadata files
/// * `GET /{repo}/repodata/primary.xml` → per-package metadata
/// * `GET /{repo}/Packages/{pkg}-{ver}-{rel}.{arch}.rpm`
///
/// **Native protocol (mirrors the conda/go plugins):** the package files are
/// served straight from the archive, and the **rpm-md index is synthesized from
/// the `.rpm` files present** when the archive doesn't already carry it.
/// `primary.xml` lists each package — name/version/release/arch parsed from the
/// filename, plus the archive byte length and a real **SHA256** of the served
/// bytes — and `repomd.xml` references `primary.xml` by SHA256 + size, so a
/// `dnf` client can resolve and verify downloads. The index is served
/// **uncompressed** (`repomd.xml` declares the `.xml` href; dnf picks the codec
/// from the extension), so no gzip dependency is pulled. If the archive already
/// contains authoritative upstream metadata (a mirrored repo) that is served
/// verbatim (path-passthrough wins); synthesis is the fallback.
///
/// Synthesized indexes are cached (the archive is immutable) so each request
/// doesn't re-hash the whole `Packages/` set — the same DoS-amplification guard
/// the conda plugin documents for `repodata.json`.
pub struct RpmRepoZnippy {
/// `base.typed()` drives the typed rpm view (`as_rpm`), the source of the
/// authoritative NEVRA incl. **epoch** the filename can't carry.
base: ZnippyBase,
/// Synthesized-index cache (`repodata/primary.xml`, `repodata/repomd.xml`).
index_cache: Mutex<HashMap<String, traits::Bytes>>,
/// The rows of the `.rpm`s (lane HOL-PERF-2), when the backend is a chain view: the
/// header fields, the size and the sha256 the scan worker read at intake.
rows: Option<Arc<dyn holger_index_rows::RowSource>>,
}
impl RpmRepoZnippy {
fn from_base(base: ZnippyBase) -> Self {
Self {
base,
index_cache: Mutex::new(HashMap::new()),
rows: None,
}
}
/// Over `reader`, with the rows of its files (lane HOL-PERF-2): the index is rendered
/// from the rows and reads no package.
pub fn with_reader_and_rows(
name: String,
reader: Arc<dyn ZnippyReader>,
rows: Arc<dyn holger_index_rows::RowSource>,
) -> Self {
Self {
rows: Some(rows),
..Self::from_base(ZnippyBase::with_reader(name, reader))
}
}
pub fn new(name: String) -> Self {
Self::from_base(ZnippyBase::new(name))
}
/// Backed by a specific znippy archive file; the typed rpm view is built once.
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
Ok(Self::from_base(ZnippyBase::with_archive(
name,
archive_path,
)?))
}
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self::from_base(ZnippyBase::with_reader(name, reader))
}
pub fn list_files(&self) -> Vec<String> {
self.base.list_files()
}
pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
self.base.get_file(relative_path)
}
/// Authoritative header metadata (NEVRA epoch + the rpm-md `primary.xml`
/// fields) from the typed rpm view — the header tag table the znippy
/// `rpm_native` plugin parsed at ingest — keyed by `(name, version, release,
/// arch)`. Empty when the archive carries no rpm sub-index (a mirrored repo, or
/// a non-rpm placeholder), so the synthesis falls back to filename-only coords
/// with `epoch=0` and no `<format>`, exactly as before.
fn header_meta(&self) -> HashMap<(String, String, String, String), RpmMetaRow> {
let mut map = HashMap::new();
if let Some(archive) = self.base.typed() {
if let Some(view) = archive.as_rpm() {
for m in view.list_meta() {
map.insert(
(
m.name.clone(),
m.version.clone(),
m.release.clone(),
m.arch.clone(),
),
m,
);
}
}
}
map
}
/// Parse an RPM filename into `(name, version, release, arch)`.
/// Canonical NEVRA filename: `{name}-{version}-{release}.{arch}.rpm`.
/// `arch` is the segment after the final `.`; `release` and `version` are the
/// last two `-`-separated fields of the remainder (neither name nor version
/// contains `-` in a valid NEVRA).
fn parse_rpm_filename(fname: &str) -> Option<(String, String, String, String)> {
let stem = fname.strip_suffix(".rpm")?;
let (rest, arch) = stem.rsplit_once('.')?;
let (name_ver, release) = rest.rsplit_once('-')?;
let (name, version) = name_ver.rsplit_once('-')?;
if name.is_empty() || version.is_empty() || release.is_empty() || arch.is_empty() {
return None;
}
Some((
name.to_string(),
version.to_string(),
release.to_string(),
arch.to_string(),
))
}
/// The `.rpm` files in the archive, sorted by archive path (determinism).
fn rpm_files(&self) -> Vec<String> {
let mut files: Vec<String> = self
.list_files()
.into_iter()
.filter(|f| f.ends_with(".rpm"))
.collect();
files.sort();
files
}
/// Build `primary.xml` from the `.rpm` files present — one `<package>` per rpm
/// with real SHA256 + size + served location. The `epoch` and the rpm-md
/// `<summary>`/`<url>`/`<format>` (license, vendor, sourcerpm, provides,
/// requires) are sourced from the znippy rpm view (the header tag table) when
/// the archive carries the rpm sub-index; otherwise it falls back to filename
/// NEVRA with `epoch=0` and no `<format>`, exactly as before.
fn build_primary(&self) -> Vec<u8> {
let files = self.rpm_files();
let meta = self.header_meta();
let mut pkgs = String::new();
let mut count = 0u64;
for f in &files {
let Some(fname) = f.rsplit('/').next() else {
continue;
};
let Some((name, version, release, arch)) = Self::parse_rpm_filename(fname) else {
continue;
};
// Authoritative header metadata, in precedence order:
// 1. the typed rpm view (the znippy `rpm_native` plugin's sub-index),
// 2. the file's index row (a chain view, lane HOL-PERF-2: the header fields,
// size and sha256 the scan worker read at intake; the .rpm is not read),
// or for an archive without rows a NATIVE parse of the served `.rpm`
// header (works for mirrored/plugin-less archives), then
// 3. filename-only NEVRA with epoch=0 and no <format>.
let typed = meta.get(&(name.clone(), version.clone(), release.clone(), arch.clone()));
let m_native;
let (size, sha) = match (&self.rows, self.rows.as_ref().and_then(|r| r.row(f))) {
(Some(_), Some(r)) => {
m_native = match &r.row {
holger_index_rows::IndexRow::Rpm {
epoch,
summary,
license,
url,
vendor,
sourcerpm,
provides,
requires,
} => {
let h = holger_index_rows::rpm::RpmHeader {
epoch: epoch.clone(),
summary: summary.clone(),
license: license.clone(),
url: url.clone(),
vendor: vendor.clone(),
sourcerpm: sourcerpm.clone(),
provides: provides.clone(),
requires: requires.clone(),
};
Some(header::meta_row(&name, &version, &release, &arch, h))
}
_ => None,
};
(r.size, r.sha256.clone())
}
// A view's file with no row: the bytes for its size and hash, never parsed.
(Some(_), None) => {
let Ok(bytes) = self.get_file(f) else {
continue;
};
m_native = None;
(bytes.len() as u64, traits::sha256_hex(&bytes))
}
(None, _) => {
let Ok(bytes) = self.get_file(f) else {
continue;
};
m_native = if typed.is_some() {
None
} else {
header::parse_rpm_header(&bytes, &name, &version, &release, &arch)
};
(bytes.len() as u64, traits::sha256_hex(&bytes))
}
};
count += 1;
let m = typed.or(m_native.as_ref());
let epoch = m
.and_then(|m| m.epoch.clone())
.unwrap_or_else(|| "0".to_string());
pkgs.push_str(" <package type=\"rpm\">\n");
pkgs.push_str(&format!(
" <name>{}</name>\n",
nornir_escape::xml_attr(&name)
));
pkgs.push_str(&format!(
" <arch>{}</arch>\n",
nornir_escape::xml_attr(&arch)
));
pkgs.push_str(&format!(
" <version epoch=\"{}\" ver=\"{}\" rel=\"{}\"/>\n",
nornir_escape::xml_attr(&epoch),
nornir_escape::xml_attr(&version),
nornir_escape::xml_attr(&release)
));
pkgs.push_str(&format!(
" <checksum type=\"sha256\" pkgid=\"YES\">{sha}</checksum>\n"
));
// Authoritative rpm-md metadata from the header, when the view has it.
if let Some(m) = m {
if let Some(s) = &m.summary {
pkgs.push_str(&format!(
" <summary>{}</summary>\n",
nornir_escape::xml_attr(s)
));
}
if let Some(u) = &m.url {
pkgs.push_str(&format!(" <url>{}</url>\n", nornir_escape::xml_attr(u)));
}
}
pkgs.push_str(&format!(" <size package=\"{size}\"/>\n"));
pkgs.push_str(&format!(
" <location href=\"{}\"/>\n",
nornir_escape::xml_attr(f)
));
if let Some(m) = m {
pkgs.push_str(&format_block(m));
}
pkgs.push_str(" </package>\n");
}
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(&format!(
"<metadata xmlns=\"http://linux.duke.edu/metadata/common\" \
xmlns:rpm=\"http://linux.duke.edu/metadata/rpm\" packages=\"{count}\">\n"
));
out.push_str(&pkgs);
out.push_str("</metadata>\n");
out.into_bytes()
}
/// Build `repomd.xml`, referencing the synthesized `primary.xml` by SHA256 +
/// size. Served uncompressed, so `checksum` == `open-checksum`.
fn build_repomd(&self) -> Vec<u8> {
// dnf fetches `primary.xml.gz` (written once, lane HOL-PERF-2): the checksum and
// size are the compressed file's, the open-checksum and open-size the XML's.
let primary = self.primary();
let gz = self.primary_gz();
let (gz_sha, gz_size) = (traits::sha256_hex(&gz), gz.len());
let sha = traits::sha256_hex(&primary);
let size = primary.len();
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(
"<repomd xmlns=\"http://linux.duke.edu/metadata/repo\" \
xmlns:rpm=\"http://linux.duke.edu/metadata/rpm\">\n",
);
out.push_str(" <data type=\"primary\">\n");
out.push_str(&format!(
" <checksum type=\"sha256\">{gz_sha}</checksum>\n"
));
out.push_str(&format!(
" <open-checksum type=\"sha256\">{sha}</open-checksum>\n"
));
out.push_str(" <location href=\"repodata/primary.xml.gz\"/>\n");
out.push_str(&format!(" <size>{gz_size}</size>\n"));
out.push_str(&format!(" <open-size>{size}</open-size>\n"));
out.push_str(" </data>\n");
out.push_str("</repomd>\n");
out.into_bytes()
}
/// Cached `primary.xml`.
fn primary(&self) -> traits::Bytes {
self.cached("repodata/primary.xml", || self.build_primary())
}
/// Cached `primary.xml.gz` (the header's time is zero, so the bytes are the content's).
fn primary_gz(&self) -> traits::Bytes {
self.cached("repodata/primary.xml.gz", || {
use std::io::Write as _;
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(&self.primary()).expect("an in-memory write");
e.finish().expect("an in-memory finish")
})
}
/// Rendered once, handed out as the same bytes (lane HOL-PERF-2): a refcount, no copy.
fn cached<F: FnOnce() -> Vec<u8>>(&self, key: &str, build: F) -> traits::Bytes {
if let Ok(cache) = self.index_cache.lock() {
if let Some(hit) = cache.get(key) {
return hit.clone();
}
}
let built = traits::Bytes::from(build());
if let Ok(mut cache) = self.index_cache.lock() {
cache.insert(key.to_string(), built.clone());
}
built
}
/// Synthesize the rpm-md index for `remainder` if it names a known index
/// file. `None` for any other path (caller then 404s).
fn synthesize_index(&self, remainder: &str) -> Option<(traits::Bytes, &'static str)> {
match remainder {
"repodata/repomd.xml" => Some((
self.cached(remainder, || self.build_repomd()),
"application/xml",
)),
"repodata/primary.xml" => Some((self.primary(), "application/xml")),
"repodata/primary.xml.gz" => Some((self.primary_gz(), "application/gzip")),
_ => None,
}
}
}
/// The rpm-md `<format>` block for one package — `rpm:license` / `rpm:vendor` /
/// `rpm:sourcerpm` plus `rpm:provides` / `rpm:requires` dependency entries —
/// emitted only for the fields the header actually carried. Returns an empty
/// string when the package has none, so `<package>` simply omits `<format>`.
fn format_block(m: &RpmMetaRow) -> String {
let mut inner = String::new();
if let Some(l) = &m.license {
inner.push_str(&format!(
" <rpm:license>{}</rpm:license>\n",
nornir_escape::xml_attr(l)
));
}
if let Some(v) = &m.vendor {
inner.push_str(&format!(
" <rpm:vendor>{}</rpm:vendor>\n",
nornir_escape::xml_attr(v)
));
}
if let Some(s) = &m.sourcerpm {
inner.push_str(&format!(
" <rpm:sourcerpm>{}</rpm:sourcerpm>\n",
nornir_escape::xml_attr(s)
));
}
let entries = |names: &[String]| -> String {
names
.iter()
.map(|n| {
format!(
" <rpm:entry name=\"{}\"/>\n",
nornir_escape::xml_attr(n)
)
})
.collect()
};
if !m.provides.is_empty() {
inner.push_str(" <rpm:provides>\n");
inner.push_str(&entries(&m.provides));
inner.push_str(" </rpm:provides>\n");
}
// `rpmlib(...)` requires are rpm's own feature markers, provided by no package: createrepo
// leaves them out, and a client resolving them against the repository refuses the package.
let requires: Vec<String> = m
.requires
.iter()
.filter(|r| !r.starts_with("rpmlib("))
.cloned()
.collect();
if !requires.is_empty() {
inner.push_str(" <rpm:requires>\n");
inner.push_str(&entries(&requires));
inner.push_str(" </rpm:requires>\n");
}
if inner.is_empty() {
String::new()
} else {
format!(" <format>\n{inner} </format>\n")
}
}
fn content_type_for(path: &str) -> &'static str {
if path.ends_with(".rpm") {
"application/x-rpm"
} else if path.ends_with(".gz") {
"application/gzip"
} else if path.ends_with(".xml") {
"application/xml"
} else {
"application/octet-stream"
}
}
impl RepositoryBackendTrait for RpmRepoZnippy {
traits::znippy_read_only!(base);
/// A package download in pieces (lane HOL-RSS): the path-passthrough the whole-body
/// door answers first, so the door holds a piece, not the package.
fn stream_get(
&self,
suburl: &str,
piece_bytes: usize,
) -> anyhow::Result<Option<traits::StreamedGet>> {
let remainder = self.base.strip_repo(suburl);
if remainder.is_empty() {
return Ok(None);
}
self.base
.stream_get(remainder, content_type_for(remainder), piece_bytes)
}
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
self.handle_http2_request_bytes(method, suburl, body)
.map(|(s, h, b)| (s, h, b.to_vec()))
}
fn handle_http2_request_bytes(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<traits::HttpBytesReply> {
use traits::znippy_base::{not_found_bytes, ok_bytes};
if method == "HEAD" {
// HOL-HEAD-1: the GET answer's status and headers, its length, no body.
return self
.handle_http2_request_bytes("GET", suburl, &[])
.map(traits::backend::head_of_bytes);
}
let _ = body;
log::debug!("rpm repo znippy handle_http2_request.suburl={}", suburl);
let remainder = self.base.strip_repo(suburl);
if remainder.is_empty() {
return Ok(not_found_bytes("Not found"));
}
// 1) Authoritative path-passthrough: packages, and any mirrored upstream
// repodata packed into the archive (real createrepo output wins).
if let Ok(data) = self.get_file(remainder) {
return Ok(ok_bytes(
content_type_for(remainder),
traits::Bytes::from(data),
));
}
// 2) Native synthesis: build the rpm-md index from the .rpm files, once.
if let Some((data, ctype)) = self.synthesize_index(remainder) {
return Ok(ok_bytes(ctype, data));
}
Ok(not_found_bytes("Not found in archive"))
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Rpm
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
// Match on the PARSED NEVRA, exact name + version — never a string
// prefix (which made `zlib 1.3.1` collide with `zlib-1.3.10`). When the
// id carries an arch in `namespace`, require it too. `rpm_files()` is
// sorted, so the pick is deterministic across runs.
for f in self.rpm_files() {
let Some(fname) = f.rsplit('/').next() else {
continue;
};
let Some((name, version, _release, arch)) = Self::parse_rpm_filename(fname) else {
continue;
};
if name != id.name || version != id.version {
continue;
}
if let Some(want_arch) = &id.namespace {
if &arch != want_arch {
continue;
}
}
if let Ok(data) = self.get_file(&f) {
return Ok(Some(traits::Bytes::from(data)));
}
}
Ok(None)
}
/// List packages parsed from the `.rpm` filenames — `(name, version)` coords
/// with the arch riding in `namespace`. Empty when no reader.
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
let mut out = Vec::new();
// ONE index read for the whole listing; every token after it is memory,
// and no chunk of any artifact is touched.
let tokens = self.base.tokens().open();
for f in self.rpm_files() {
let Some(fname) = f.rsplit('/').next() else {
continue;
};
let Some((name, version, _release, arch)) = Self::parse_rpm_filename(fname) else {
continue;
};
if let Some(flt) = name_filter {
if !name.contains(flt) {
continue;
}
}
let size_bytes = self
.base
.reader()
.and_then(|r| r.file_size(&f))
.unwrap_or(0) as i64;
out.push(
traits::ArtifactEntry {
id: ArtifactId {
namespace: Some(arch),
name,
version,
},
size_bytes,
content_type: "application/x-rpm".into(),
// No mtime and no content digest in a znippy file table;
// the CHANGE TOKEN below is what the index CAN answer, and
// it reads no artifact byte.
..traits::ArtifactEntry::UNKNOWN_PROVENANCE
}
.with_change_token(tokens.as_ref().and_then(|t| t.token(&f))),
);
if limit != 0 && out.len() >= limit {
break;
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use traits::test_support::MemReader;
#[test]
fn caps_and_methods_agree() {
traits::backend::conformance::check(&RpmRepoZnippy::new("e".into())).unwrap();
let loaded = RpmRepoZnippy::with_reader(
"l".into(),
MemReader::new([("a-1-1.x86_64.rpm", b"x".to_vec())]),
);
assert!(loaded.caps().archive);
traits::backend::conformance::check(&loaded).unwrap();
}
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
/// RED-when-broken guard on the shared `nornir_escape::xml_attr` consumed here
/// (the deleted local `xml_escape` twin). repomd/primary attributes are
/// single-quoted, so the escaper MUST keep the apos-inclusive 5-entity variant:
/// `&`→`&`, `<`→`<`, `>`→`>`, `"`→`"`, `'`→`'`. This asserts
/// the emitted `<format>` block is BYTE-IDENTICAL to the old twin's output —
/// including `'`. Swap to plain `nornir_escape::xml` (drops `'`) and
/// the license row here fails, catching a repomd regression.
#[test]
fn format_block_xml_attr_escapes_all_five_entities_incl_apos() {
let m = RpmMetaRow {
name: "pkg".into(),
version: "1.0".into(),
release: "1".into(),
arch: "x86_64".into(),
epoch: None,
summary: None,
// Every sensitive char, apostrophe included.
license: Some("GPL & \"MIT\" <v1> it's".into()),
url: None,
vendor: None,
sourcerpm: None,
provides: vec!["lib'a & <b>".into()],
requires: vec![],
};
let block = format_block(&m);
// Exact, byte-for-byte — `'` renders as `'`, NOT `'` nor a raw `'`.
assert_eq!(
block,
" <format>\n\
\x20 <rpm:license>GPL & "MIT" <v1> it's</rpm:license>\n\
\x20 <rpm:provides>\n\
\x20 <rpm:entry name=\"lib'a & <b>\"/>\n\
\x20 </rpm:provides>\n\
\x20 </format>\n",
"format block must be byte-identical incl ':\n{block}"
);
}
#[test]
fn test_new() {
let repo = RpmRepoZnippy::new("rpm-test".to_string());
assert_eq!(repo.name(), "rpm-test");
assert!(!repo.is_writable());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-rpm",
"new_name_and_readonly",
repo.name() == "rpm-test" && !repo.is_writable(),
&format!("name={} writable={}", repo.name(), repo.is_writable()),
);
}
#[test]
fn test_format() {
let f = RpmRepoZnippy::new("x".into()).format();
assert_eq!(f, ArtifactFormat::Rpm);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-rpm",
"format_is_rpm",
f == ArtifactFormat::Rpm,
&format!("format = {f:?}"),
);
}
/// **Capability row** (CLAUDE.md prod-vs-prototype: RPM header parse). The
/// `repomd.xml`/`primary.xml` synthesis carries real SHA256, and the epoch +
/// `<format>` fields now come from a real binary RPM header parse of the served
/// bytes (the `header` module) when the typed rpm view is absent — filename
/// NEVRA is only the last-resort fallback. Proven by
/// [`native_header_parse_enriches_primary`]; emitted `ok=true`.
#[test]
fn rpm_header_parse_capability() {
let rpm =
super::header::tests_support::make_rpm(&[super::header::tests_support::Tag::Str(
1014, "GPLv3+",
)]);
let parsed = super::header::parse_rpm_header(&rpm, "hello", "2.10", "1", "x86_64")
.map(|m| m.license.as_deref() == Some("GPLv3+"))
.unwrap_or(false);
assert!(parsed, "native RPM header parse must read the license tag");
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-rpm",
"binary_header_parse",
parsed,
"primary.xml epoch/<format> parsed from the real RPM header tag table; real SHA256",
);
}
/// The native RPM header parse ENRICHES the synthesized `primary.xml`: served a
/// bare `.rpm` (no typed rpm view, so `header_meta()` is empty), holger still
/// surfaces the real epoch, `<summary>`/`<url>`, and the `<format>` block
/// (license / provides / requires) parsed from the package's own header — not
/// just the filename NEVRA with `epoch=0`. The capability the maturity row asserts.
/// **`rpmlib(...)` requires are never listed** (lane HOL-MVP-FORMATS, measured with a real
/// `rpmbuild` package and `dnf install` through holger 2026-10-30: "nothing provides
/// rpmlib(CompressedFileNames) needed by hello-holger"). They are rpm's own feature
/// markers, satisfied by rpm itself and provided by no package, so createrepo leaves them
/// out of `primary.xml`; listing them makes every real package uninstallable.
#[test]
fn rpmlib_requires_are_never_listed_in_primary() {
use super::header::tests_support::{make_rpm, Tag};
let rpm = make_rpm(&[Tag::StrArray(
1049,
&[
"rpmlib(CompressedFileNames)",
"/bin/sh",
"rpmlib(PayloadIsZstd)",
],
)]);
let reader = MemReader::new([("hello-holger-1.0.0-1.noarch.rpm", rpm)]);
let repo = RpmRepoZnippy::with_reader("rpm-test".into(), reader);
let (st, _h, body) = repo
.handle_http2_request("GET", "/rpm-test/repodata/primary.xml", &[])
.expect("ok");
let primary = String::from_utf8(body).expect("utf8");
assert_eq!(st, 200);
assert!(
primary.contains("<rpm:entry name=\"/bin/sh\"/>"),
"a real requirement stays:\n{primary}"
);
assert!(
!primary.contains("rpmlib("),
"rpmlib() requires are never listed:\n{primary}"
);
}
#[test]
fn native_header_parse_enriches_primary() {
use super::header::tests_support::{make_rpm, Tag};
let rpm = make_rpm(&[
Tag::Int32(1003, 7), // epoch
Tag::Str(1004, "zlib compression library"), // summary
Tag::Str(1014, "Zlib"), // license
Tag::Str(1020, "https://zlib.net"), // url
Tag::StrArray(1047, &["zlib", "libz.so.1"]), // provides
Tag::StrArray(1049, &["libc.so.6"]), // requires
]);
let key = "Packages/zlib-1.3.1-2.fc40.x86_64.rpm";
let reader = MemReader::new([(key, rpm)]);
// `with_reader` ⇒ no typed archive view ⇒ header_meta() empty, so these
// fields can ONLY come from the native header parse.
let repo = RpmRepoZnippy::with_reader("rpm-test".into(), reader);
assert!(
repo.header_meta().is_empty(),
"no typed view in with_reader mode"
);
let (st, _h, body) = repo
.handle_http2_request("GET", "/rpm-test/repodata/primary.xml", &[])
.expect("ok");
let primary = String::from_utf8(body).expect("utf8");
assert_eq!(st, 200);
// Filename NEVRA still present…
assert!(primary.contains("<name>zlib</name>"), "primary:\n{primary}");
// …enriched with the natively-parsed epoch + rpm-md fields.
assert!(primary.contains("epoch=\"7\""), "primary:\n{primary}");
assert!(
primary.contains("<summary>zlib compression library</summary>"),
"primary:\n{primary}"
);
assert!(
primary.contains("<url>https://zlib.net</url>"),
"primary:\n{primary}"
);
assert!(
primary.contains("<rpm:license>Zlib</rpm:license>"),
"primary:\n{primary}"
);
assert!(
primary.contains("<rpm:entry name=\"libz.so.1\"/>"),
"primary:\n{primary}"
);
assert!(
primary.contains("<rpm:entry name=\"libc.so.6\"/>"),
"primary:\n{primary}"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-rpm",
"native_header_parse_enriches_primary",
primary.contains("epoch=\"7\"") && primary.contains("<rpm:license>Zlib</rpm:license>"),
"RPM header parse surfaced epoch + <format> with no typed view",
);
}
#[test]
fn parse_nevra_filename() {
let parsed = RpmRepoZnippy::parse_rpm_filename("zlib-1.3.1-2.fc40.x86_64.rpm");
assert_eq!(
parsed,
Some((
"zlib".into(),
"1.3.1".into(),
"2.fc40".into(),
"x86_64".into()
))
);
}
#[test]
fn serves_injected_archive() {
// Archive key is the dnf-style path-passthrough layout:
// Packages/{pkg}-{ver}-{rel}.{arch}.rpm
let key = "Packages/zlib-1.3.1-2.fc40.x86_64.rpm";
let expected = b"\xed\xab\xee\xdb-fake-rpm-payload".to_vec();
let reader = MemReader::new([(key, expected.clone())]);
let repo = RpmRepoZnippy::with_reader("rpm-test".to_string(), reader);
// fetch() matches on "{name}-{version}" against the .rpm filename.
let id = ArtifactId {
namespace: None,
name: "zlib".into(),
version: "1.3.1".into(),
};
let fetched = repo.fetch(&id).expect("fetch ok");
assert_eq!(fetched.as_deref(), Some(expected.as_slice()));
// HTTP/2 hit: GET /{repo}/{key} -> 200, application/x-rpm, exact body.
let url = format!("/{}/{}", "rpm-test", key);
let (status, headers, body) = repo
.handle_http2_request("GET", &url, &[])
.expect("handle ok");
assert_eq!(status, 200);
assert_eq!(body, expected);
let ctype = headers
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
assert_eq!(ctype, Some("application/x-rpm"));
// HTTP/2 miss: unknown package -> 404.
let miss_url = "/rpm-test/Packages/nope-9.9.9-1.fc40.x86_64.rpm";
let (miss_status, _miss_headers, _miss_body) = repo
.handle_http2_request("GET", miss_url, &[])
.expect("handle ok");
assert_eq!(miss_status, 404);
// fetch() miss returns None.
let miss_id = ArtifactId {
namespace: None,
name: "nope".into(),
version: "9.9.9".into(),
};
let miss_fetch = repo.fetch(&miss_id).expect("fetch ok");
assert!(miss_fetch.is_none());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-rpm",
"serves_injected_archive",
fetched.as_deref() == Some(expected.as_slice())
&& status == 200
&& body == expected
&& ctype == Some("application/x-rpm")
&& miss_status == 404
&& miss_fetch.is_none(),
&format!(
"key={key} status={status} ctype={ctype:?} bytes={} miss_status={miss_status}",
body.len()
),
);
}
/// Native protocol: `repomd.xml` + `primary.xml` are synthesized from the
/// `.rpm` files (real size + SHA256), and `repomd.xml` references the
/// `primary.xml` it would serve by hash — the rpm-md index dnf resolves
/// before downloading.
#[test]
fn synthesizes_rpm_md_index_from_packages() {
let key = "Packages/zlib-1.3.1-2.fc40.x86_64.rpm";
let rpm_bytes = b"\xed\xab\xee\xdb-fake-rpm-payload".to_vec();
let reader = MemReader::new([(key, rpm_bytes.clone())]);
let repo = RpmRepoZnippy::with_reader("rpm-test".to_string(), reader);
// primary.xml: 200 + application/xml, one <package> with the NEVRA fields,
// real size + SHA256, and a <location> pointing at the served rpm path.
let (pst, ph, pbody) = repo
.handle_http2_request("GET", "/rpm-test/repodata/primary.xml", &[])
.expect("ok");
let primary = String::from_utf8(pbody.clone()).expect("utf8");
let pctype = ph
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
let expected_sha = traits::sha256_hex(&rpm_bytes);
let primary_ok = pst == 200
&& pctype == Some("application/xml")
&& primary.contains("packages=\"1\"")
&& primary.contains("<name>zlib</name>")
&& primary.contains("<arch>x86_64</arch>")
&& primary.contains("ver=\"1.3.1\" rel=\"2.fc40\"")
&& primary.contains(&format!("pkgid=\"YES\">{expected_sha}</checksum>"))
&& primary.contains(&format!("<size package=\"{}\"/>", rpm_bytes.len()))
&& primary.contains(&format!("<location href=\"{key}\"/>"));
assert!(primary_ok, "primary.xml malformed:\n{primary}");
// repomd.xml: 200, names primary.xml.gz (lane HOL-PERF-2) and opens to the
// SHA256 of the bytes the server would (and just did) serve for primary.xml.
let (rst, _rh, rbody) = repo
.handle_http2_request("GET", "/rpm-test/repodata/repomd.xml", &[])
.expect("ok");
let repomd = String::from_utf8(rbody).expect("utf8");
let primary_sha = traits::sha256_hex(&pbody);
let repomd_ok = rst == 200
&& repomd.contains("<location href=\"repodata/primary.xml.gz\"/>")
&& repomd.contains(&format!(
"<open-checksum type=\"sha256\">{primary_sha}</open-checksum>"
))
&& repomd.contains(&format!("<open-size>{}</open-size>", pbody.len()));
assert!(repomd_ok, "repomd.xml malformed:\n{repomd}");
#[cfg(feature = "testmatrix")]
{
fstatus(
"znippy-rpm",
"synthesizes_primary_xml",
primary_ok,
&format!("status={pst} sha={expected_sha}"),
);
fstatus(
"znippy-rpm",
"synthesizes_repomd_xml",
repomd_ok,
&format!("status={rst} primary_sha={primary_sha}"),
);
}
}
/// T21: fetch must not collide a version prefix (`1.3.1` vs `1.3.10`), must
/// honour the arch hint, and `primary.xml` must carry one `<package>` per rpm
/// with the real per-body size + sha256 and no two sharing location/sha.
#[test]
fn fetch_no_version_collision_and_multi_package_primary() {
let b131 = b"zlib-1.3.1-body".to_vec();
let b1310 = b"zlib-1.3.10-body-different".to_vec();
let bi686 = b"zlib-1.3.1-i686-body".to_vec();
let reader = MemReader::new([
("Packages/zlib-1.3.1-2.fc40.x86_64.rpm", b131.clone()),
("Packages/zlib-1.3.10-1.fc40.x86_64.rpm", b1310.clone()),
("Packages/zlib-1.3.1-2.fc40.i686.rpm", bi686.clone()),
]);
let repo = RpmRepoZnippy::with_reader("rpm-test".into(), reader);
// EXACT version: 1.3.1 must never return the 1.3.10 bytes.
let id = ArtifactId {
namespace: None,
name: "zlib".into(),
version: "1.3.1".into(),
};
let got = repo.fetch(&id).expect("ok");
// Without an arch hint either 1.3.1 build is acceptable, but never 1.3.10.
let got = got.expect("a 1.3.1 build is found");
assert_ne!(
got, b1310,
"fetch(zlib,1.3.1) must NOT return the 1.3.10 body"
);
assert!(
got == b131 || got == bi686,
"fetch returned a real 1.3.1 build"
);
// Arch hint pins the exact build.
let id_i686 = ArtifactId {
namespace: Some("i686".into()),
name: "zlib".into(),
version: "1.3.1".into(),
};
assert_eq!(
repo.fetch(&id_i686).expect("ok").as_deref(),
Some(bi686.as_slice())
);
let id_x86 = ArtifactId {
namespace: Some("x86_64".into()),
name: "zlib".into(),
version: "1.3.1".into(),
};
assert_eq!(
repo.fetch(&id_x86).expect("ok").as_deref(),
Some(b131.as_slice())
);
// primary.xml: one <package> per rpm, sizes + sha256 match the bodies,
// no two packages share a location or a checksum.
let (pst, _ph, pbody) = repo
.handle_http2_request("GET", "/rpm-test/repodata/primary.xml", &[])
.expect("ok");
let primary = String::from_utf8(pbody).expect("utf8");
let pkg_count = primary.matches("<package type=\"rpm\">").count();
let collision_ok = pst == 200
&& primary.contains("packages=\"3\"")
&& pkg_count == 3
&& primary.contains(&format!("<size package=\"{}\"/>", b131.len()))
&& primary.contains(&format!("<size package=\"{}\"/>", b1310.len()))
&& primary.contains(&traits::sha256_hex(&b131))
&& primary.contains(&traits::sha256_hex(&b1310))
&& primary.contains(&traits::sha256_hex(&bi686));
assert!(
collision_ok,
"primary.xml missing a per-rpm row:\n{primary}"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-rpm",
"fetch_no_version_collision",
collision_ok && got != b1310,
&format!("3 packages, exact-version fetch, arch-pinned (count={pkg_count})"),
);
}
/// **A chain view's `primary.xml` is rendered from the rows** (lane HOL-PERF-2): byte
/// for byte the index the in-process header walk gives, and not one `.rpm` is read.
#[test]
fn with_rows_the_primary_index_reads_no_rpm_and_is_the_parsed_one() {
use super::header::tests_support::{make_rpm, Tag};
use traits::test_support::{CountingReader, MemReader};
let rpm = make_rpm(&[
Tag::Int32(1003, 1),
Tag::Str(1004, "A friendly greeter"),
Tag::Str(1014, "GPLv3+"),
Tag::StrArray(1047, &["hello"]),
Tag::StrArray(1049, &["libc.so.6"]),
]);
let path = "Packages/hello-2.10-1.x86_64.rpm";
let parsed = RpmRepoZnippy::with_reader("r".into(), MemReader::new([(path, rpm.clone())]));
let counting = CountingReader::new(MemReader::new([(path, rpm.clone())]));
let viewed = RpmRepoZnippy::with_reader_and_rows(
"r".into(),
counting.clone(),
holger_index_rows::MapRows::of("rpm", &[(path, &rpm)]),
);
for url in ["/r/repodata/primary.xml", "/r/repodata/repomd.xml"] {
let want = parsed.handle_http2_request("GET", url, &[]).unwrap();
assert_eq!(want.0, 200, "{url}");
let got = viewed.handle_http2_request("GET", url, &[]).unwrap();
assert_eq!(
String::from_utf8_lossy(&got.2),
String::from_utf8_lossy(&want.2),
"{url}, byte for byte"
);
}
assert!(String::from_utf8_lossy(
&viewed
.handle_http2_request("GET", "/r/repodata/primary.xml", &[])
.unwrap()
.2
)
.contains("A friendly greeter"));
assert_eq!(counting.hits(), 0, "no .rpm is read to index it");
}
/// **Rendered once, handed out as the same bytes** (lane HOL-PERF-2, X5).
#[test]
fn the_rpm_md_index_is_one_allocation_handed_out_again() {
use super::header::tests_support::{make_rpm, Tag};
use traits::test_support::MemReader;
let rpm = make_rpm(&[Tag::Str(1004, "a greeter")]);
let repo = RpmRepoZnippy::with_reader(
"r".into(),
MemReader::new([("Packages/hello-2.10-1.x86_64.rpm", rpm)]),
);
for url in ["/r/repodata/primary.xml", "/r/repodata/repomd.xml"] {
let a = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
let b = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
assert_eq!(a.0, 200, "{url}");
assert_eq!(
a.2.as_ptr(),
b.2.as_ptr(),
"{url}: the same bytes, not a copy"
);
}
}
/// **`primary.xml.gz` is written once and named by `repomd.xml`** (lane HOL-PERF-2,
/// step 6): dnf moves about a tenth of the bytes; `primary.xml` stays served.
#[test]
fn primary_xml_gz_is_written_once_and_named_by_repomd() {
use super::header::tests_support::{make_rpm, Tag};
use std::io::Read as _;
use traits::test_support::MemReader;
let repo = RpmRepoZnippy::with_reader(
"r".into(),
MemReader::new([(
"Packages/hello-2.10-1.x86_64.rpm",
make_rpm(&[Tag::Str(1004, "a greeter")]),
)]),
);
let plain = repo
.handle_http2_request_bytes("GET", "/r/repodata/primary.xml", &[])
.unwrap()
.2;
let gz = repo
.handle_http2_request_bytes("GET", "/r/repodata/primary.xml.gz", &[])
.unwrap();
assert_eq!(gz.0, 200);
let mut out = Vec::new();
flate2::read::GzDecoder::new(&gz.2[..])
.read_to_end(&mut out)
.unwrap();
assert_eq!(out, &plain[..], "primary.xml.gz is primary.xml");
assert_eq!(
gz.2.as_ptr(),
repo.handle_http2_request_bytes("GET", "/r/repodata/primary.xml.gz", &[])
.unwrap()
.2
.as_ptr(),
"written once"
);
let repomd = String::from_utf8(
repo.handle_http2_request("GET", "/r/repodata/repomd.xml", &[])
.unwrap()
.2,
)
.unwrap();
assert!(
repomd.contains("<location href=\"repodata/primary.xml.gz\"/>"),
"{repomd}"
);
assert!(
repomd.contains(&format!(
"<checksum type=\"sha256\">{}</checksum>",
traits::sha256_hex(&gz.2)
)),
"{repomd}"
);
assert!(
repomd.contains(&format!(
"<open-checksum type=\"sha256\">{}</open-checksum>",
traits::sha256_hex(&plain)
)),
"{repomd}"
);
}
}