Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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());
    }
}