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

nordisk/holger

server/inspect/src/rpmdb.rs

//! **The rpm database, read from its SQLite file** (`rpmdb.sqlite`, rpm 4.16+:
//! Fedora 33+, RHEL/UBI 9, Rocky/Alma 9). It runs in the scan worker over bytes
//! from an image layer, so it is a small, strictly bounded, READ-ONLY reader with
//! no C and no engine: it walks one table's b-tree (`Packages(hnum, blob)`) and
//! parses each row's rpm header blob for NAME, EPOCH, VERSION, RELEASE and ARCH.
//!
//! What it does not do: apply a write-ahead log (`rpmdb.sqlite-wal`; an image's
//! is empty, rpm checkpoints on close), read an index, or read the older
//! BerkeleyDB (`Packages`) and ndb (`Packages.db`) databases (the cataloger names
//! those under `not_read`).
//!
//! Every read is checked against the file: a page number past the end, a cycle,
//! a cell outside its page, a payload past [`MAX_PAYLOAD`] or more than
//! [`MAX_ROWS`] rows is an error, never a panic or a loop.

use std::collections::BTreeSet;

/// The largest row payload (one rpm header) read.
pub const MAX_PAYLOAD: usize = 64 << 20;
/// The most rows (packages) read from one table.
pub const MAX_ROWS: usize = 200_000;
/// The deepest b-tree walked.
const MAX_DEPTH: usize = 32;

/// One installed rpm, as its header says.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Rpm {
    pub name: String,
    pub epoch: Option<u32>,
    pub version: String,
    pub release: String,
    pub arch: Option<String>,
}

impl Rpm {
    /// `[epoch:]version-release`, as rpm advisories write it.
    pub fn evr(&self) -> String {
        match self.epoch.filter(|e| *e > 0) {
            Some(e) => format!("{e}:{}-{}", self.version, self.release),
            None => format!("{}-{}", self.version, self.release),
        }
    }
}

type R<T> = Result<T, String>;

struct Db<'a> {
    b: &'a [u8],
    page_size: usize,
    usable: usize,
    pages: usize,
}

fn be16(b: &[u8], at: usize) -> R<usize> {
    b.get(at..at + 2)
        .map(|x| u16::from_be_bytes([x[0], x[1]]) as usize)
        .ok_or_else(|| "a read past the page".into())
}

fn be32(b: &[u8], at: usize) -> R<u32> {
    b.get(at..at + 4)
        .map(|x| u32::from_be_bytes([x[0], x[1], x[2], x[3]]))
        .ok_or_else(|| "a read past the page".into())
}

/// A SQLite varint at `at`: its value and its length.
fn varint(b: &[u8], at: usize) -> R<(u64, usize)> {
    let mut v: u64 = 0;
    for i in 0..9 {
        let x = *b.get(at + i).ok_or("a varint past the page")?;
        if i == 8 {
            return Ok(((v << 8) | x as u64, 9));
        }
        v = (v << 7) | (x & 0x7f) as u64;
        if x & 0x80 == 0 {
            return Ok((v, i + 1));
        }
    }
    unreachable!()
}

impl<'a> Db<'a> {
    fn open(b: &'a [u8]) -> R<Self> {
        if b.len() < 100 || &b[..16] != b"SQLite format 3\0" {
            return Err("not a SQLite 3 database".into());
        }
        let raw = be16(b, 16)?;
        let page_size = if raw == 1 { 65536 } else { raw };
        if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() {
            return Err(format!("page size {page_size} is not a SQLite page size"));
        }
        let reserved = b[20] as usize;
        let usable = page_size
            .checked_sub(reserved)
            .filter(|u| *u >= 480)
            .ok_or("the usable page size is too small")?;
        Ok(Self {
            b,
            page_size,
            usable,
            pages: b.len() / page_size,
        })
    }

    /// Page `n` (1-based) and the offset of its b-tree header in it.
    fn page(&self, n: u32) -> R<(&'a [u8], usize)> {
        let n = n as usize;
        if n == 0 || n > self.pages {
            return Err(format!(
                "page {n} is outside the file ({} pages)",
                self.pages
            ));
        }
        let start = (n - 1) * self.page_size;
        Ok((
            &self.b[start..start + self.page_size],
            if n == 1 { 100 } else { 0 },
        ))
    }

    /// The payload of a table-leaf cell at `cell`, with its overflow chain.
    fn payload(&self, page: &[u8], cell: usize, seen: &mut BTreeSet<u32>) -> R<(i64, Vec<u8>)> {
        let (p, l1) = varint(page, cell)?;
        let (rowid, l2) = varint(page, cell + l1)?;
        let p = p as usize;
        if p > MAX_PAYLOAD {
            return Err(format!("a row of {p} bytes, past the cap of {MAX_PAYLOAD}"));
        }
        let start = cell + l1 + l2;
        let u = self.usable;
        let x = u - 35;
        let local = if p <= x {
            p
        } else {
            let m = ((u - 12) * 32 / 255) - 23;
            let k = m + ((p - m) % (u - 4));
            if k <= x {
                k
            } else {
                m
            }
        };
        let mut out = Vec::with_capacity(p);
        out.extend_from_slice(
            page.get(start..start + local)
                .ok_or("a cell past its page")?,
        );
        if local < p {
            let mut next = be32(page, start + local)?;
            while out.len() < p {
                if !seen.insert(next) {
                    return Err(format!("overflow page {next} is visited twice"));
                }
                let (ov, _) = self.page(next)?;
                let take = (p - out.len()).min(u - 4);
                out.extend_from_slice(ov.get(4..4 + take).ok_or("an overflow page past its end")?);
                next = be32(ov, 0)?;
            }
        }
        Ok((rowid as i64, out))
    }

    /// Every row of the table b-tree rooted at `root`, in rowid order.
    fn rows(&self, root: u32) -> R<Vec<(i64, Vec<u8>)>> {
        let mut out = Vec::new();
        let mut seen = BTreeSet::new();
        self.walk(root, 0, &mut seen, &mut out)?;
        Ok(out)
    }

    fn walk(
        &self,
        n: u32,
        depth: usize,
        seen: &mut BTreeSet<u32>,
        out: &mut Vec<(i64, Vec<u8>)>,
    ) -> R<()> {
        if depth > MAX_DEPTH {
            return Err("the b-tree is deeper than the cap".into());
        }
        if !seen.insert(n) {
            return Err(format!("page {n} is visited twice"));
        }
        let (page, h) = self.page(n)?;
        let kind = *page.get(h).ok_or("an empty page")?;
        let cells = be16(page, h + 3)?;
        let (hdr, interior) = match kind {
            0x0d => (8, false),
            0x05 => (12, true),
            k => {
                return Err(format!(
                    "page {n} is not a table b-tree page (type {k:#04x})"
                ))
            }
        };
        for i in 0..cells {
            let cell = be16(page, h + hdr + 2 * i)?;
            if cell >= self.usable {
                return Err(format!("a cell pointer of page {n} is outside the page"));
            }
            if interior {
                self.walk(be32(page, cell)?, depth + 1, seen, out)?;
            } else {
                if out.len() >= MAX_ROWS {
                    return Err(format!("more than {MAX_ROWS} rows"));
                }
                out.push(self.payload(page, cell, seen)?);
            }
        }
        if interior {
            self.walk(be32(page, h + 8)?, depth + 1, seen, out)?;
        }
        Ok(())
    }
}

/// One column value of a record.
#[derive(Debug, Clone, PartialEq)]
enum Val {
    Null,
    Int(i64),
    Bytes(Vec<u8>),
}

/// A record's columns.
fn record(p: &[u8]) -> R<Vec<Val>> {
    let (hlen, mut at) = varint(p, 0)?;
    let hlen = hlen as usize;
    if hlen > p.len() {
        return Err("a record header past its payload".into());
    }
    let mut types = Vec::new();
    while at < hlen {
        let (t, l) = varint(p, at)?;
        types.push(t);
        at += l;
    }
    let mut body = hlen;
    let mut out = Vec::with_capacity(types.len());
    for t in types {
        let int = |n: usize, body: &mut usize| -> R<i64> {
            let b = p.get(*body..*body + n).ok_or("a column past its record")?;
            *body += n;
            let mut v: i64 = if b[0] & 0x80 != 0 { -1 } else { 0 };
            for x in b {
                v = (v << 8) | *x as i64;
            }
            Ok(v)
        };
        let v = match t {
            0 => Val::Null,
            1 => Val::Int(int(1, &mut body)?),
            2 => Val::Int(int(2, &mut body)?),
            3 => Val::Int(int(3, &mut body)?),
            4 => Val::Int(int(4, &mut body)?),
            5 => Val::Int(int(6, &mut body)?),
            6 | 7 => Val::Int(int(8, &mut body)?),
            8 => Val::Int(0),
            9 => Val::Int(1),
            10 | 11 => return Err("a reserved serial type".into()),
            n => {
                let len = ((n - if n % 2 == 0 { 12 } else { 13 }) / 2) as usize;
                let b = p.get(body..body + len).ok_or("a column past its record")?;
                body += len;
                Val::Bytes(b.to_vec())
            }
        };
        out.push(v);
    }
    Ok(out)
}

/// The root page of table `name`, from `sqlite_master` (page 1).
fn root_of(db: &Db<'_>, name: &str) -> R<u32> {
    for (_, p) in db.rows(1)? {
        let cols = record(&p)?;
        let text = |i: usize| match cols.get(i) {
            Some(Val::Bytes(b)) => String::from_utf8_lossy(b).into_owned(),
            _ => String::new(),
        };
        if text(0) == "table" && text(1) == name {
            return match cols.get(3) {
                Some(Val::Int(r)) if *r > 0 && *r <= u32::MAX as i64 => Ok(*r as u32),
                _ => Err(format!("table {name} has no root page")),
            };
        }
    }
    Err(format!("the database has no {name} table"))
}

// ── the rpm header blob ──────────────────────────────────────────────────────

const TAG_NAME: u32 = 1000;
const TAG_VERSION: u32 = 1001;
const TAG_RELEASE: u32 = 1002;
const TAG_EPOCH: u32 = 1003;
const TAG_ARCH: u32 = 1022;
const TYPE_INT32: u32 = 4;
const TYPE_STRING: u32 = 6;

/// Read NAME, EPOCH, VERSION, RELEASE and ARCH from one header blob (the
/// database's form: index count, data length, the index, the data store).
pub fn parse_header(b: &[u8]) -> R<Rpm> {
    let il = be32(b, 0)? as usize;
    let dl = be32(b, 4)? as usize;
    if il > 0x10000 || dl > MAX_PAYLOAD {
        return Err("an rpm header larger than the cap".into());
    }
    let data_at = 8 + 16 * il;
    let data = b
        .get(data_at..data_at + dl)
        .ok_or("an rpm header shorter than it says")?;
    let (mut name, mut version, mut release, mut epoch, mut arch) = (None, None, None, None, None);
    for i in 0..il {
        let e = 8 + 16 * i;
        let (tag, ty, off) = (be32(b, e)?, be32(b, e + 4)?, be32(b, e + 8)? as usize);
        let string = || -> R<String> {
            let s = data.get(off..).ok_or("a header string past the data")?;
            let end = s
                .iter()
                .position(|c| *c == 0)
                .ok_or("an unterminated header string")?;
            Ok(String::from_utf8_lossy(&s[..end]).into_owned())
        };
        match (tag, ty) {
            (TAG_NAME, TYPE_STRING) => name = Some(string()?),
            (TAG_VERSION, TYPE_STRING) => version = Some(string()?),
            (TAG_RELEASE, TYPE_STRING) => release = Some(string()?),
            (TAG_ARCH, TYPE_STRING) => arch = Some(string()?),
            (TAG_EPOCH, TYPE_INT32) => epoch = Some(be32(data, off)?),
            _ => {}
        }
    }
    match (name, version, release) {
        (Some(name), Some(version), Some(release)) => Ok(Rpm {
            name,
            epoch,
            version,
            release,
            arch,
        }),
        _ => Err("an rpm header without NAME, VERSION or RELEASE".into()),
    }
}

/// **Every package in an `rpmdb.sqlite`.** The `gpg-pubkey` pseudo-packages
/// (imported signing keys) are not packages and are left out.
pub fn packages(bytes: &[u8]) -> R<Vec<Rpm>> {
    let db = Db::open(bytes)?;
    let root = root_of(&db, "Packages")?;
    let mut out = Vec::new();
    for (rowid, p) in db.rows(root)? {
        let cols = record(&p)?;
        let blob = cols
            .iter()
            .find_map(|c| match c {
                Val::Bytes(b) => Some(b),
                _ => None,
            })
            .ok_or_else(|| format!("row {rowid} of Packages holds no header blob"))?;
        let rpm = parse_header(blob).map_err(|e| format!("row {rowid}: {e}"))?;
        if rpm.name != "gpg-pubkey" {
            out.push(rpm);
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    const UBI9_MICRO: &[u8] = include_bytes!("../tests/fixtures/rpm/ubi9-micro-rpmdb.sqlite.gz");

    fn ubi9() -> Vec<u8> {
        use std::io::Read as _;
        let mut out = Vec::new();
        flate2::read::GzDecoder::new(UBI9_MICRO)
            .read_to_end(&mut out)
            .unwrap();
        out
    }

    /// A real `rpmdb.sqlite` (UBI 9 micro, its one layer, 2026-10-24): 22 rows,
    /// two of them `gpg-pubkey`, one header (`filesystem`) 5 MB long and so on a
    /// long overflow chain.
    #[test]
    fn a_real_ubi9_rpmdb_reads_every_package() {
        let p = packages(&ubi9()).unwrap();
        assert_eq!(p.len(), 20, "{p:?}");
        let got: Vec<(String, String)> = p.iter().map(|r| (r.name.clone(), r.evr())).collect();
        for want in [
            ("bash", "5.1.8-9.el9"),
            ("glibc", "2.34-283.el9_8"),
            ("coreutils-single", "8.32-41.el9_8.1"),
            ("filesystem", "3.16-5.el9"),
            ("tzdata", "2026e-1.el9_8"),
            ("redhat-release", "9.8-1.0.el9"),
        ] {
            assert!(
                got.contains(&(want.0.to_string(), want.1.to_string())),
                "{want:?} in {got:?}"
            );
        }
        assert!(!got.iter().any(|(n, _)| n == "gpg-pubkey"));
        let glibc = p.iter().find(|r| r.name == "glibc").unwrap();
        assert_eq!(glibc.arch.as_deref(), Some("x86_64"));
    }

    #[test]
    fn a_damaged_database_is_an_error_not_a_panic() {
        let good = ubi9();
        assert!(packages(b"not sqlite").is_err());
        assert!(
            packages(&good[..4096]).is_err(),
            "the Packages table is past the first page"
        );
        // Every 4 KiB page's first bytes overwritten: errors, never a panic or a loop.
        let mut bad = good.clone();
        for p in (4096..bad.len()).step_by(4096) {
            bad[p..p + 12].copy_from_slice(&[0x05, 0, 0, 0, 0x01, 0x10, 0, 0, 0, 0, 0, 2]);
        }
        assert_ne!(
            packages(&bad).ok().map(|p| p.len()),
            Some(20),
            "a damaged database does not read as the good one"
        );
        let mut odd = good;
        odd[16] = 0;
        odd[17] = 3;
        assert!(packages(&odd).is_err(), "a page size of 3 is refused");
    }

    #[test]
    fn an_epoch_is_written_before_the_version() {
        let r = Rpm {
            name: "x".into(),
            epoch: Some(2),
            version: "1.0".into(),
            release: "3.el9".into(),
            arch: None,
        };
        assert_eq!(r.evr(), "2:1.0-3.el9");
        assert_eq!(
            Rpm {
                epoch: Some(0),
                ..r
            }
            .evr(),
            "1.0-3.el9"
        );
    }
}