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