mod parse;
use anyhow::{Context, Result, bail};
use camino::{Utf8Path, Utf8PathBuf};
use cap_std_ext::cap_std::fs::Dir;
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::os::fd::AsRawFd;
use std::path::Path;
use std::process::Command;
pub type Packages = HashMap<String, Package>;
pub type Files = BTreeMap<Utf8PathBuf, FileInfo>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DigestAlgorithm {
Md5 = 1,
Sha1 = 2,
RipeMd160 = 3,
Md2 = 5,
Tiger192 = 6,
Haval5160 = 7,
Sha256 = 8,
Sha384 = 9,
Sha512 = 10,
Sha224 = 11,
Sha3_256 = 12,
Sha3_512 = 14,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FileFlags(u32);
impl FileFlags {
pub const CONFIG: u32 = 1 << 0;
pub const DOC: u32 = 1 << 1;
pub const MISSINGOK: u32 = 1 << 3;
pub const NOREPLACE: u32 = 1 << 4;
pub const GHOST: u32 = 1 << 6;
pub const LICENSE: u32 = 1 << 7;
pub const README: u32 = 1 << 8;
pub const ARTIFACT: u32 = 1 << 12;
pub fn from_raw(value: u32) -> Self {
Self(value)
}
pub fn raw(&self) -> u32 {
self.0
}
pub fn is_config(&self) -> bool {
self.0 & Self::CONFIG != 0
}
pub fn is_doc(&self) -> bool {
self.0 & Self::DOC != 0
}
pub fn is_missingok(&self) -> bool {
self.0 & Self::MISSINGOK != 0
}
pub fn is_noreplace(&self) -> bool {
self.0 & Self::NOREPLACE != 0
}
pub fn is_ghost(&self) -> bool {
self.0 & Self::GHOST != 0
}
pub fn is_license(&self) -> bool {
self.0 & Self::LICENSE != 0
}
pub fn is_readme(&self) -> bool {
self.0 & Self::README != 0
}
pub fn is_artifact(&self) -> bool {
self.0 & Self::ARTIFACT != 0
}
}
#[derive(Debug, Clone)]
pub struct FileInfo {
pub size: u64,
pub mode: u16,
pub mtime: u64,
pub digest: Option<String>,
pub flags: FileFlags,
pub user: String,
pub group: String,
pub linkto: Option<Utf8PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Package {
pub name: String,
pub version: String,
pub release: String,
pub epoch: Option<u32>,
pub arch: String,
pub license: String,
pub size: u64,
pub buildtime: u64,
pub installtime: u64,
pub sourcerpm: Option<String>,
pub digest_algo: Option<DigestAlgorithm>,
pub changelog_times: Vec<u64>,
pub files: Files,
}
pub fn load_from_reader<R: Read>(reader: R) -> Result<Packages> {
parse::load_from_reader_impl(reader)
}
pub fn load_from_str(s: &str) -> Result<Packages> {
parse::load_from_str_impl(s)
}
pub fn load_from_rootfs(rootfs: &Utf8Path) -> Result<Packages> {
run_rpm(rootfs.as_str())
}
pub fn load_from_rootfs_dir(rootfs: &Dir) -> Result<Packages> {
use rustix::io::dup;
let duped = dup(rootfs).context("failed to dup rootfs fd")?;
let rootfs_path = format!("/proc/self/fd/{}", duped.as_raw_fd());
run_rpm(&rootfs_path)
}
const RPMDB_PATHS: &[&str] = &["usr/lib/sysimage/rpm", "var/lib/rpm", "usr/share/rpm"];
fn find_dbpath(rootfs: &Path) -> Result<Option<&'static str>> {
for dbpath in RPMDB_PATHS {
if std::fs::exists(rootfs.join(dbpath)).context("failed to probe rpmdb path")? {
return Ok(Some(dbpath));
}
}
Ok(None)
}
fn run_rpm(rootfs_path: &str) -> Result<Packages> {
let mut cmd = Command::new("rpm");
cmd.arg("--root").arg(rootfs_path);
if let Some(dbpath) = find_dbpath(Path::new(rootfs_path))? {
cmd.arg("--dbpath").arg(format!("/{dbpath}"));
}
cmd.args(["-qa", "--queryformat", parse::QUERYFORMAT]);
cmd.stdout(std::process::Stdio::piped());
let mut child = cmd.spawn().context("failed to run rpm")?;
let stdout = child
.stdout
.take()
.context("failed to capture rpm stdout")?;
let packages = load_from_reader(stdout);
let status = child.wait().context("failed to wait for rpm")?;
if !status.success() {
match status.code() {
Some(code) => bail!("rpm command failed (exit code {})", code),
None => {
use std::os::unix::process::ExitStatusExt;
bail!(
"rpm command killed by signal {}",
status.signal().unwrap_or(0)
)
}
}
}
packages
}
pub fn load() -> Result<Packages> {
load_from_rootfs(Utf8Path::new("/"))
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = include_str!("../tests/fixtures/fedora.qf");
fn setup_test_rootfs_at(rpmdb_relpath: &str) -> tempfile::TempDir {
let tmpdir = tempfile::tempdir().expect("failed to create tempdir");
let rpmdb_dir = tmpdir.path().join(rpmdb_relpath);
std::fs::create_dir_all(&rpmdb_dir).expect("failed to create rpmdb dir");
std::fs::copy(
"tests/fixtures/rpmdb.sqlite",
rpmdb_dir.join("rpmdb.sqlite"),
)
.expect("failed to copy rpmdb.sqlite");
tmpdir
}
fn setup_test_rootfs() -> tempfile::TempDir {
setup_test_rootfs_at("usr/lib/sysimage/rpm")
}
fn assert_has_test_packages(packages: &Packages) {
assert!(packages.contains_key("filesystem"));
assert!(packages.contains_key("setup"));
assert!(packages.contains_key("fedora-release"));
}
#[test]
fn test_load_from_rootfs() {
let tmpdir = setup_test_rootfs();
let rootfs = Utf8Path::from_path(tmpdir.path()).expect("non-utf8 path");
let packages = load_from_rootfs(rootfs).expect("failed to load packages");
assert_has_test_packages(&packages);
}
#[test]
fn test_load_from_rootfs_dir() {
let tmpdir = setup_test_rootfs();
let rootfs_dir =
Dir::open_ambient_dir(tmpdir.path(), cap_std_ext::cap_std::ambient_authority())
.expect("failed to open rootfs dir");
let packages = load_from_rootfs_dir(&rootfs_dir).expect("failed to load packages");
assert_has_test_packages(&packages);
}
#[test]
fn test_load_from_rootfs_legacy_dbpath() {
let tmpdir = setup_test_rootfs_at("var/lib/rpm");
let rootfs = Utf8Path::from_path(tmpdir.path()).expect("non-utf8 path");
let packages = load_from_rootfs(rootfs).expect("failed to load packages");
assert_has_test_packages(&packages);
}
#[test]
fn test_load_from_str() {
let packages = load_from_str(FIXTURE).expect("failed to load packages");
assert!(!packages.is_empty(), "expected at least one package");
for (name, pkg) in &packages {
assert_eq!(name, &pkg.name);
assert!(!pkg.version.is_empty());
assert!(!pkg.arch.is_empty());
}
assert!(packages.contains_key("glibc"));
assert!(packages.contains_key("bash"));
assert!(packages.contains_key("coreutils"));
assert_eq!(packages["bash"].epoch, None);
assert_eq!(packages["shadow-utils"].epoch, Some(2));
assert_eq!(packages["perl-POSIX"].epoch, Some(0));
}
#[test]
fn test_load_from_reader() {
let packages = load_from_reader(FIXTURE.as_bytes()).expect("failed to load packages");
assert!(!packages.is_empty(), "expected at least one package");
assert!(packages.get("rpm").is_some());
}
#[test]
fn test_file_parsing() {
let packages = load_from_str(FIXTURE).expect("failed to load packages");
let bash = packages.get("bash").expect("bash package not found");
assert!(!bash.files.is_empty(), "bash should have files");
let bash_bin = bash
.files
.get(Utf8Path::new("/usr/bin/bash"))
.expect("/usr/bin/bash not found");
assert!(bash_bin.size > 0, "bash binary should have non-zero size");
assert!(bash_bin.digest.is_some(), "bash binary should have digest");
assert_eq!(bash.digest_algo, Some(DigestAlgorithm::Sha256));
assert!(
!bash_bin.flags.is_config(),
"bash binary is not a config file"
);
assert_eq!(bash_bin.user, "root");
assert_eq!(bash_bin.group, "root");
let bashrc = bash
.files
.get(Utf8Path::new("/etc/skel/.bashrc"))
.expect("/etc/skel/.bashrc not found");
assert!(bashrc.flags.is_config(), ".bashrc should be a config file");
assert!(bashrc.flags.is_noreplace(), ".bashrc should be noreplace");
let sh = bash
.files
.get(Utf8Path::new("/usr/bin/sh"))
.expect("/usr/bin/sh not found");
assert!(sh.linkto.is_some(), "/usr/bin/sh should be a symlink");
assert_eq!(sh.linkto.as_ref().unwrap(), "bash");
let setup = packages.get("setup").expect("setup package not found");
let motd = setup
.files
.get(Utf8Path::new("/run/motd"))
.expect("/run/motd not found");
assert!(motd.flags.is_ghost(), "/run/motd should be a ghost");
assert!(!motd.flags.is_config(), "/run/motd is not a config file");
assert!(motd.digest.is_none(), "ghost files have no digest");
let fstab = setup
.files
.get(Utf8Path::new("/etc/fstab"))
.expect("/etc/fstab not found");
assert!(fstab.flags.is_ghost(), "/etc/fstab should be a ghost");
assert!(
fstab.flags.is_config(),
"/etc/fstab should be a config file"
);
assert!(fstab.flags.is_missingok(), "/etc/fstab should be missingok");
assert!(fstab.flags.is_noreplace(), "/etc/fstab should be noreplace");
}
#[test]
fn test_directory_ownership() {
let packages = load_from_str(FIXTURE).expect("failed to load packages");
let rpm = packages.get("rpm").expect("rpm package not found");
let fedora_release = packages
.get("fedora-release-common")
.expect("fedora-release-common package not found");
let macros_d = rpm
.files
.get(Utf8Path::new("/usr/lib/rpm/macros.d"))
.expect("/usr/lib/rpm/macros.d not found in rpm");
assert_eq!(
macros_d.mode & 0o170000,
0o040000,
"macros.d should be a directory"
);
assert!(
fedora_release
.files
.contains_key(Utf8Path::new("/usr/lib/rpm/macros.d/macros.dist")),
"/usr/lib/rpm/macros.d/macros.dist not found in fedora-release-common"
);
assert!(
rpm.files
.get(Utf8Path::new("/usr/lib/rpm/macros.d/macros.dist"))
.is_none(),
"macros.dist should not be owned by rpm"
);
assert!(
fedora_release
.files
.get(Utf8Path::new("/usr/lib/rpm/macros.d"))
.is_none(),
"macros.d directory should not be owned by fedora-release-common"
);
}
#[test]
fn test_single_file_scalar_values() {
let packages = load_from_str(FIXTURE).expect("failed to load packages");
let pkg = packages
.get("langpacks-core-en")
.expect("langpacks-core-en package not found");
assert_eq!(pkg.name, "langpacks-core-en");
assert_eq!(pkg.version, "4.2");
assert_eq!(pkg.release, "5.fc43");
assert_eq!(pkg.files.len(), 1);
let file = pkg
.files
.get(Utf8Path::new(
"/usr/share/metainfo/org.fedoraproject.LangPack-Core-en.metainfo.xml",
))
.expect("metainfo.xml not found");
assert_eq!(file.size, 398);
assert_eq!(file.user, "root");
assert_eq!(file.group, "root");
assert_eq!(
file.digest.as_deref(),
Some("d0ba061c715c73b91d2be66ab40adfab510ed4e69cf5d40970733e211de38ce6")
);
}
#[test]
fn test_changelog_times() {
let packages = load_from_str(FIXTURE).expect("failed to load packages");
let bash = packages.get("bash").expect("bash package not found");
assert!(
!bash.changelog_times.is_empty(),
"bash should have changelog entries"
);
let min_valid_time = 1577836800u64; for &time in &bash.changelog_times {
assert!(time > min_valid_time, "changelog time {} is too old", time);
}
}
}