server/index-rows/src/rpm.rs
//! Native RPM header parse — reads the authoritative NEVRA epoch and the rpm-md
//! `<format>` fields (license / vendor / URL / summary / sourcerpm / provides /
//! requires) straight out of the `.rpm` header tag table, so holger enriches
//! `primary.xml` even for archives that carry no typed rpm sub-index (a mirrored
//! repo of bare `.rpm`s, or one built without the znippy `rpm_native` plugin).
//!
//! An `.rpm` is: a 96-byte **lead**, a **signature header**, then the **main
//! header** — both headers share the RPMv3 "header" structure:
//! `[8e ad e8 01] [4 reserved] [nindex:u32be] [hsize:u32be]` followed by
//! `nindex` 16-byte index entries `(tag, type, offset, count)` and an `hsize`-byte
//! data store the offsets point into. The signature header's store is padded to an
//! 8-byte boundary before the main header. We parse the main header's tag table
//! and pull the fields the rpm-md `<package>` needs.
//!
//! Every read is bounds-checked; malformed or hostile bytes yield `None` (the
//! caller then keeps its filename-derived NEVRA with `epoch=0`). No `unsafe`, no
//! panics, no allocation beyond the extracted strings.
//!
//! Moved here from `znippy-rpm` (lane HOL-PERF-2): the scan worker runs it at intake and
//! answers the fields as an [`crate::IndexRow::Rpm`]; `znippy-rpm` keeps its
//! `parse_rpm_header` over [`header`] for an archive that carries no rows.
const LEAD_LEN: usize = 96;
const LEAD_MAGIC: [u8; 4] = [0xed, 0xab, 0xee, 0xdb];
const HEADER_MAGIC: [u8; 3] = [0x8e, 0xad, 0xe8];
const INDEX_ENTRY_LEN: usize = 16;
// RPM header tags (subset the rpm-md `<package>` needs).
const RPMTAG_EPOCH: u32 = 1003;
const RPMTAG_SUMMARY: u32 = 1004;
const RPMTAG_VENDOR: u32 = 1011;
const RPMTAG_LICENSE: u32 = 1014;
const RPMTAG_URL: u32 = 1020;
const RPMTAG_SOURCERPM: u32 = 1044;
const RPMTAG_PROVIDENAME: u32 = 1047;
const RPMTAG_REQUIRENAME: u32 = 1049;
// RPM data types.
const RPM_INT32_TYPE: u32 = 4;
const RPM_STRING_TYPE: u32 = 6;
const RPM_STRING_ARRAY_TYPE: u32 = 8;
const RPM_I18NSTRING_TYPE: u32 = 9;
/// Cap the tag count / store size we'll consider — a real RPM header has a few
/// hundred tags and a store well under a MiB; anything vastly larger is malformed
/// or a resource-exhaustion attempt, not metadata.
const MAX_NINDEX: usize = 65_536;
const MAX_STORE: usize = 64 * 1024 * 1024;
fn be_u32(data: &[u8], at: usize) -> Option<u32> {
let b = data.get(at..at + 4)?;
Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
/// One parsed index entry, offsets already validated against the store length.
struct Entry {
tag: u32,
ty: u32,
offset: usize,
count: usize,
}
/// Return the byte offset just past a header structure that begins at `start`
/// (i.e. the end of its data store), validating magic + bounds. Used to skip the
/// signature header without fully decoding it.
fn header_end(data: &[u8], start: usize) -> Option<usize> {
if data.get(start..start + 3)? != HEADER_MAGIC {
return None;
}
let nindex = be_u32(data, start + 8)? as usize;
let hsize = be_u32(data, start + 12)? as usize;
if nindex > MAX_NINDEX || hsize > MAX_STORE {
return None;
}
let index_len = nindex.checked_mul(INDEX_ENTRY_LEN)?;
let store_start = start.checked_add(16)?.checked_add(index_len)?;
let end = store_start.checked_add(hsize)?;
if end > data.len() {
return None;
}
Some(end)
}
/// Fully decode the header at `start`: its index entries and a slice of its data
/// store.
fn parse_header(data: &[u8], start: usize) -> Option<(Vec<Entry>, &[u8])> {
if data.get(start..start + 3)? != HEADER_MAGIC {
return None;
}
let nindex = be_u32(data, start + 8)? as usize;
let hsize = be_u32(data, start + 12)? as usize;
if nindex > MAX_NINDEX || hsize > MAX_STORE {
return None;
}
let index_start = start.checked_add(16)?;
let index_len = nindex.checked_mul(INDEX_ENTRY_LEN)?;
let store_start = index_start.checked_add(index_len)?;
let store_end = store_start.checked_add(hsize)?;
if store_end > data.len() {
return None;
}
let store = &data[store_start..store_end];
let mut entries = Vec::with_capacity(nindex);
for i in 0..nindex {
let e = index_start + i * INDEX_ENTRY_LEN;
let tag = be_u32(data, e)?;
let ty = be_u32(data, e + 4)?;
let offset = be_u32(data, e + 8)? as usize;
let count = be_u32(data, e + 12)? as usize;
// Offset must land inside the store; per-type length is checked at read.
if offset > store.len() {
continue;
}
entries.push(Entry {
tag,
ty,
offset,
count,
});
}
Some((entries, store))
}
/// Read a NUL-terminated string starting at `offset` in the store.
fn read_string(store: &[u8], offset: usize) -> Option<String> {
let rest = store.get(offset..)?;
let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
std::str::from_utf8(&rest[..end]).ok().map(str::to_string)
}
/// Read `count` consecutive NUL-terminated strings (STRING_ARRAY / I18NSTRING).
fn read_string_array(store: &[u8], offset: usize, count: usize) -> Vec<String> {
let mut out = Vec::new();
let mut pos = offset;
for _ in 0..count.min(MAX_NINDEX) {
let Some(rest) = store.get(pos..) else { break };
if rest.is_empty() {
break;
}
let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
if let Ok(s) = std::str::from_utf8(&rest[..end]) {
out.push(s.to_string());
}
pos += end + 1;
}
out
}
fn find(entries: &[Entry], tag: u32) -> Option<&Entry> {
entries.iter().find(|e| e.tag == tag)
}
fn read_tag_string(entries: &[Entry], store: &[u8], tag: u32) -> Option<String> {
let e = find(entries, tag)?;
if e.ty != RPM_STRING_TYPE && e.ty != RPM_I18NSTRING_TYPE {
return None;
}
read_string(store, e.offset).filter(|s| !s.is_empty())
}
fn read_tag_string_array(entries: &[Entry], store: &[u8], tag: u32) -> Vec<String> {
match find(entries, tag) {
Some(e) if e.ty == RPM_STRING_ARRAY_TYPE => read_string_array(store, e.offset, e.count)
.into_iter()
.filter(|s| !s.is_empty())
.collect(),
_ => Vec::new(),
}
}
fn read_tag_int32(entries: &[Entry], store: &[u8], tag: u32) -> Option<u32> {
let e = find(entries, tag)?;
if e.ty != RPM_INT32_TYPE {
return None;
}
be_u32(store, e.offset)
}
/// The header fields the rpm-md `<package>` needs (the NEVRA comes from the file name).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RpmHeader {
pub epoch: Option<String>,
pub summary: Option<String>,
pub license: Option<String>,
pub url: Option<String>,
pub vendor: Option<String>,
pub sourcerpm: Option<String>,
pub provides: Vec<String>,
pub requires: Vec<String>,
}
/// Parse the header fields of a `.rpm`. Returns `None` when the bytes aren't a
/// decodable RPM (bad lead, truncated headers, …).
pub fn header(rpm: &[u8]) -> Option<RpmHeader> {
crate::counted();
if rpm.len() < LEAD_LEN || rpm[..4] != LEAD_MAGIC {
return None;
}
// Signature header immediately follows the lead; skip it (8-byte aligned).
let sig_end = header_end(rpm, LEAD_LEN)?;
let main_start = (sig_end + 7) & !7;
let (entries, store) = parse_header(rpm, main_start)?;
let epoch = read_tag_int32(&entries, store, RPMTAG_EPOCH).map(|e| e.to_string());
Some(RpmHeader {
epoch,
summary: read_tag_string(&entries, store, RPMTAG_SUMMARY),
license: read_tag_string(&entries, store, RPMTAG_LICENSE),
url: read_tag_string(&entries, store, RPMTAG_URL),
vendor: read_tag_string(&entries, store, RPMTAG_VENDOR),
sourcerpm: read_tag_string(&entries, store, RPMTAG_SOURCERPM),
provides: read_tag_string_array(&entries, store, RPMTAG_PROVIDENAME),
requires: read_tag_string_array(&entries, store, RPMTAG_REQUIRENAME),
})
}
/// Test-only RPM synthesis — a byte-exact minimal `.rpm` (lead + empty signature
/// header + a main header carrying the chosen tags) so the crate's `lib.rs`
/// integration tests can drive the native parse without a binary fixture.
#[cfg(any(test, feature = "fixtures"))]
pub mod tests_support {
use super::*;
/// A tag to encode into the synthetic main header.
pub enum Tag<'a> {
Str(u32, &'a str),
Int32(u32, u32),
StrArray(u32, &'a [&'a str]),
}
fn push_be_u32(out: &mut Vec<u8>, v: u32) {
out.extend_from_slice(&v.to_be_bytes());
}
/// Encode a header structure (magic + index + store) for `tags`.
fn encode_header(tags: &[Tag]) -> Vec<u8> {
let mut index = Vec::new();
let mut store = Vec::new();
for tag in tags {
match tag {
Tag::Str(t, s) => {
let offset = store.len() as u32;
push_be_u32(&mut index, *t);
push_be_u32(&mut index, RPM_STRING_TYPE);
push_be_u32(&mut index, offset);
push_be_u32(&mut index, 1);
store.extend_from_slice(s.as_bytes());
store.push(0);
}
Tag::Int32(t, v) => {
let offset = store.len() as u32;
push_be_u32(&mut index, *t);
push_be_u32(&mut index, RPM_INT32_TYPE);
push_be_u32(&mut index, offset);
push_be_u32(&mut index, 1);
push_be_u32(&mut store, *v);
}
Tag::StrArray(t, items) => {
let offset = store.len() as u32;
push_be_u32(&mut index, *t);
push_be_u32(&mut index, RPM_STRING_ARRAY_TYPE);
push_be_u32(&mut index, offset);
push_be_u32(&mut index, items.len() as u32);
for it in *items {
store.extend_from_slice(it.as_bytes());
store.push(0);
}
}
}
}
let mut out = Vec::new();
out.extend_from_slice(&HEADER_MAGIC);
out.push(0x01);
out.extend_from_slice(&[0, 0, 0, 0]);
push_be_u32(&mut out, tags.len() as u32);
push_be_u32(&mut out, store.len() as u32);
out.extend_from_slice(&index);
out.extend_from_slice(&store);
out
}
/// Assemble a full minimal `.rpm`: lead + empty signature header + a main
/// header carrying `tags`, with the 8-byte signature/main alignment padding.
pub fn make_rpm(tags: &[Tag]) -> Vec<u8> {
let mut out = Vec::new();
// Lead: magic + 92 zero bytes.
out.extend_from_slice(&LEAD_MAGIC);
out.resize(LEAD_LEN, 0);
// Empty signature header.
out.extend_from_slice(&encode_header(&[]));
// Pad to 8-byte boundary before the main header.
while out.len() % 8 != 0 {
out.push(0);
}
out.extend_from_slice(&encode_header(tags));
out
}
}
#[cfg(test)]
mod tests {
use super::tests_support::{make_rpm, Tag};
use super::*;
#[test]
fn parses_full_header() {
let rpm = make_rpm(&[
Tag::Int32(RPMTAG_EPOCH, 2),
Tag::Str(RPMTAG_SUMMARY, "A friendly greeter"),
Tag::Str(RPMTAG_LICENSE, "GPLv3+"),
Tag::Str(RPMTAG_URL, "https://example.org/hello"),
Tag::Str(RPMTAG_VENDOR, "Fedora Project"),
Tag::Str(RPMTAG_SOURCERPM, "hello-2.10-1.src.rpm"),
Tag::StrArray(RPMTAG_PROVIDENAME, &["hello", "hello(x86-64)"]),
Tag::StrArray(RPMTAG_REQUIRENAME, &["libc.so.6", "rtld(GNU_HASH)"]),
]);
let m = header(&rpm).expect("parse");
assert_eq!(m.epoch.as_deref(), Some("2"));
assert_eq!(m.summary.as_deref(), Some("A friendly greeter"));
assert_eq!(m.license.as_deref(), Some("GPLv3+"));
assert_eq!(m.url.as_deref(), Some("https://example.org/hello"));
assert_eq!(m.vendor.as_deref(), Some("Fedora Project"));
assert_eq!(m.sourcerpm.as_deref(), Some("hello-2.10-1.src.rpm"));
assert_eq!(
m.provides,
vec!["hello".to_string(), "hello(x86-64)".to_string()]
);
assert_eq!(
m.requires,
vec!["libc.so.6".to_string(), "rtld(GNU_HASH)".to_string()]
);
}
#[test]
fn missing_optional_tags_are_none() {
// Only a summary; everything else absent.
let rpm = make_rpm(&[Tag::Str(RPMTAG_SUMMARY, "minimal")]);
let m = header(&rpm).expect("parse");
assert_eq!(m.summary.as_deref(), Some("minimal"));
assert_eq!(m.epoch, None);
assert_eq!(m.license, None);
assert!(m.provides.is_empty());
assert!(m.requires.is_empty());
}
#[test]
fn non_rpm_bytes_yield_none() {
assert!(header(b"not an rpm").is_none());
assert!(header(b"").is_none());
// Right lead magic, truncated (no signature header).
let mut short = LEAD_MAGIC.to_vec();
short.resize(LEAD_LEN, 0);
assert!(header(&short).is_none());
}
#[test]
fn truncated_store_yields_none() {
let mut rpm = make_rpm(&[Tag::Str(RPMTAG_SUMMARY, "cut me off")]);
rpm.truncate(rpm.len() - 4); // chop into the main store
assert!(header(&rpm).is_none());
}
}