Skip to main content

rpm_qa/
lib.rs

1//! A thin Rust wrapper around `rpm -qa`
2//!
3//! This crate provides functions to load and parse the output from
4//! `rpm -qa --queryformat`, returning package metadata as a map of package
5//! names to `Package` structs.
6//!
7//! Uses `--queryformat` instead of `--json` for compatibility with older RPM.
8
9mod parse;
10
11use anyhow::{Context, Result, bail};
12use camino::{Utf8Path, Utf8PathBuf};
13use cap_std_ext::cap_std::fs::Dir;
14use std::collections::{BTreeMap, HashMap};
15use std::io::Read;
16use std::os::fd::AsRawFd;
17use std::path::Path;
18use std::process::Command;
19
20/// A map of package names to their metadata.
21pub type Packages = HashMap<String, Package>;
22
23/// A map of file paths to their metadata.
24pub type Files = BTreeMap<Utf8PathBuf, FileInfo>;
25
26/// Cryptographic hash algorithm used for file digests.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DigestAlgorithm {
29    /// MD5 (legacy, insecure).
30    Md5 = 1,
31    /// SHA-1 (legacy, insecure).
32    Sha1 = 2,
33    /// RIPEMD-160.
34    RipeMd160 = 3,
35    /// MD2 (obsolete).
36    Md2 = 5,
37    /// TIGER-192.
38    Tiger192 = 6,
39    /// HAVAL-5-160.
40    Haval5160 = 7,
41    /// SHA-256 (current default).
42    Sha256 = 8,
43    /// SHA-384.
44    Sha384 = 9,
45    /// SHA-512.
46    Sha512 = 10,
47    /// SHA-224.
48    Sha224 = 11,
49    /// SHA3-256.
50    Sha3_256 = 12,
51    /// SHA3-512.
52    Sha3_512 = 14,
53}
54
55/// File attribute flags from the RPM spec file.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub struct FileFlags(u32);
58
59impl FileFlags {
60    /// File is a configuration file (`%config`).
61    pub const CONFIG: u32 = 1 << 0;
62    /// File is documentation (`%doc`).
63    pub const DOC: u32 = 1 << 1;
64    /// Missing file is OK (`%config(missingok)`).
65    pub const MISSINGOK: u32 = 1 << 3;
66    /// Don't replace existing file (`%config(noreplace)`).
67    pub const NOREPLACE: u32 = 1 << 4;
68    /// File is a ghost (`%ghost`).
69    pub const GHOST: u32 = 1 << 6;
70    /// File is a license (`%license`).
71    pub const LICENSE: u32 = 1 << 7;
72    /// File is a README (`%readme`).
73    pub const README: u32 = 1 << 8;
74    /// File is a build artifact (`%artifact`).
75    pub const ARTIFACT: u32 = 1 << 12;
76
77    /// Create from raw flag value.
78    pub fn from_raw(value: u32) -> Self {
79        Self(value)
80    }
81
82    /// Get the raw flag value.
83    pub fn raw(&self) -> u32 {
84        self.0
85    }
86
87    /// Check if the config flag is set.
88    pub fn is_config(&self) -> bool {
89        self.0 & Self::CONFIG != 0
90    }
91
92    /// Check if the doc flag is set.
93    pub fn is_doc(&self) -> bool {
94        self.0 & Self::DOC != 0
95    }
96
97    /// Check if the missingok flag is set.
98    pub fn is_missingok(&self) -> bool {
99        self.0 & Self::MISSINGOK != 0
100    }
101
102    /// Check if the noreplace flag is set.
103    pub fn is_noreplace(&self) -> bool {
104        self.0 & Self::NOREPLACE != 0
105    }
106
107    /// Check if the ghost flag is set.
108    pub fn is_ghost(&self) -> bool {
109        self.0 & Self::GHOST != 0
110    }
111
112    /// Check if the license flag is set.
113    pub fn is_license(&self) -> bool {
114        self.0 & Self::LICENSE != 0
115    }
116
117    /// Check if the readme flag is set.
118    pub fn is_readme(&self) -> bool {
119        self.0 & Self::README != 0
120    }
121
122    /// Check if the artifact flag is set.
123    pub fn is_artifact(&self) -> bool {
124        self.0 & Self::ARTIFACT != 0
125    }
126}
127
128/// Metadata for a file contained in an RPM package.
129#[derive(Debug, Clone)]
130pub struct FileInfo {
131    /// File size in bytes.
132    pub size: u64,
133    /// Unix file mode (permissions and type).
134    pub mode: u16,
135    /// Unix modification timestamp.
136    pub mtime: u64,
137    /// Hex-encoded file digest, if present (directories and symlinks have none).
138    pub digest: Option<String>,
139    /// File attribute flags.
140    pub flags: FileFlags,
141    /// Owner username.
142    pub user: String,
143    /// Owner group name.
144    pub group: String,
145    /// Symlink target, if this is a symbolic link.
146    pub linkto: Option<Utf8PathBuf>,
147}
148
149/// Metadata for an installed RPM package.
150#[derive(Debug, Clone)]
151pub struct Package {
152    /// Package name.
153    pub name: String,
154    /// Package version.
155    pub version: String,
156    /// Package release.
157    pub release: String,
158    /// Package epoch, if present.
159    pub epoch: Option<u32>,
160    /// The architecture the package is for. `noarch` is a special case denoting
161    /// an architecture independent package.
162    pub arch: String,
163    /// License of the package contents.
164    pub license: String,
165    /// Installed package size.
166    pub size: u64,
167    /// Unix timestamp of package build time.
168    pub buildtime: u64,
169    /// Unix timestamp of package installation.
170    pub installtime: u64,
171    /// Package source rpm file name.
172    pub sourcerpm: Option<String>,
173    /// Digest algorithm used for file digests in this package.
174    pub digest_algo: Option<DigestAlgorithm>,
175    /// Unix timestamps of changelog entries (most recent first).
176    pub changelog_times: Vec<u64>,
177    /// Files contained in this package.
178    pub files: Files,
179}
180
181/// Load packages from a reader containing queryformat output.
182pub fn load_from_reader<R: Read>(reader: R) -> Result<Packages> {
183    parse::load_from_reader_impl(reader)
184}
185
186/// Load packages from a string containing queryformat output.
187pub fn load_from_str(s: &str) -> Result<Packages> {
188    parse::load_from_str_impl(s)
189}
190
191/// Load all installed RPM packages from a rootfs path by running `rpm -qa`.
192pub fn load_from_rootfs(rootfs: &Utf8Path) -> Result<Packages> {
193    run_rpm(rootfs.as_str())
194}
195
196/// Load all installed RPM packages from a rootfs directory by running `rpm -qa`.
197pub fn load_from_rootfs_dir(rootfs: &Dir) -> Result<Packages> {
198    use rustix::io::dup;
199    // Dup the fd as a way to clear O_CLOEXEC so rpm can access it.
200    // See also CapStdExtCommandExt::take_fn_n() though here we don't leak.
201    let duped = dup(rootfs).context("failed to dup rootfs fd")?;
202    let rootfs_path = format!("/proc/self/fd/{}", duped.as_raw_fd());
203    run_rpm(&rootfs_path)
204}
205
206/// Note the host `rpm` resolves `%_dbpath` from its own macro context, not the
207/// target rootfs's. We probe the rootfs to find where the rpmdb actually is and
208/// pass `--dbpath` explicitly to avoid mismatches (e.g. Fedora host reading a
209/// RHEL 9 rootfs).
210const RPMDB_PATHS: &[&str] = &["usr/lib/sysimage/rpm", "var/lib/rpm", "usr/share/rpm"];
211
212fn find_dbpath(rootfs: &Path) -> Result<Option<&'static str>> {
213    for dbpath in RPMDB_PATHS {
214        if std::fs::exists(rootfs.join(dbpath)).context("failed to probe rpmdb path")? {
215            return Ok(Some(dbpath));
216        }
217    }
218    Ok(None)
219}
220
221fn run_rpm(rootfs_path: &str) -> Result<Packages> {
222    let mut cmd = Command::new("rpm");
223    cmd.arg("--root").arg(rootfs_path);
224    if let Some(dbpath) = find_dbpath(Path::new(rootfs_path))? {
225        cmd.arg("--dbpath").arg(format!("/{dbpath}"));
226    }
227    cmd.args(["-qa", "--queryformat", parse::QUERYFORMAT]);
228    cmd.stdout(std::process::Stdio::piped());
229    let mut child = cmd.spawn().context("failed to run rpm")?;
230    let stdout = child
231        .stdout
232        .take()
233        .context("failed to capture rpm stdout")?;
234
235    let packages = load_from_reader(stdout);
236
237    let status = child.wait().context("failed to wait for rpm")?;
238    if !status.success() {
239        match status.code() {
240            Some(code) => bail!("rpm command failed (exit code {})", code),
241            None => {
242                use std::os::unix::process::ExitStatusExt;
243                bail!(
244                    "rpm command killed by signal {}",
245                    status.signal().unwrap_or(0)
246                )
247            }
248        }
249    }
250
251    packages
252}
253
254/// Load all installed RPM packages by running `rpm -qa`.
255pub fn load() -> Result<Packages> {
256    load_from_rootfs(Utf8Path::new("/"))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    const FIXTURE: &str = include_str!("../tests/fixtures/fedora.qf");
264
265    fn setup_test_rootfs_at(rpmdb_relpath: &str) -> tempfile::TempDir {
266        let tmpdir = tempfile::tempdir().expect("failed to create tempdir");
267        let rpmdb_dir = tmpdir.path().join(rpmdb_relpath);
268        std::fs::create_dir_all(&rpmdb_dir).expect("failed to create rpmdb dir");
269        std::fs::copy(
270            "tests/fixtures/rpmdb.sqlite",
271            rpmdb_dir.join("rpmdb.sqlite"),
272        )
273        .expect("failed to copy rpmdb.sqlite");
274        tmpdir
275    }
276
277    fn setup_test_rootfs() -> tempfile::TempDir {
278        setup_test_rootfs_at("usr/lib/sysimage/rpm")
279    }
280
281    fn assert_has_test_packages(packages: &Packages) {
282        assert!(packages.contains_key("filesystem"));
283        assert!(packages.contains_key("setup"));
284        assert!(packages.contains_key("fedora-release"));
285    }
286
287    #[test]
288    fn test_load_from_rootfs() {
289        let tmpdir = setup_test_rootfs();
290        let rootfs = Utf8Path::from_path(tmpdir.path()).expect("non-utf8 path");
291        let packages = load_from_rootfs(rootfs).expect("failed to load packages");
292        assert_has_test_packages(&packages);
293    }
294
295    #[test]
296    fn test_load_from_rootfs_dir() {
297        let tmpdir = setup_test_rootfs();
298        let rootfs_dir =
299            Dir::open_ambient_dir(tmpdir.path(), cap_std_ext::cap_std::ambient_authority())
300                .expect("failed to open rootfs dir");
301        let packages = load_from_rootfs_dir(&rootfs_dir).expect("failed to load packages");
302        assert_has_test_packages(&packages);
303    }
304
305    #[test]
306    fn test_load_from_rootfs_legacy_dbpath() {
307        let tmpdir = setup_test_rootfs_at("var/lib/rpm");
308        let rootfs = Utf8Path::from_path(tmpdir.path()).expect("non-utf8 path");
309        let packages = load_from_rootfs(rootfs).expect("failed to load packages");
310        assert_has_test_packages(&packages);
311    }
312
313    #[test]
314    fn test_load_from_str() {
315        let packages = load_from_str(FIXTURE).expect("failed to load packages");
316        assert!(!packages.is_empty(), "expected at least one package");
317
318        for (name, pkg) in &packages {
319            assert_eq!(name, &pkg.name);
320            assert!(!pkg.version.is_empty());
321            assert!(!pkg.arch.is_empty());
322        }
323
324        // Check specific packages from fixture
325        assert!(packages.contains_key("glibc"));
326        assert!(packages.contains_key("bash"));
327        assert!(packages.contains_key("coreutils"));
328
329        // bash has no epoch
330        assert_eq!(packages["bash"].epoch, None);
331        // shadow-utils has epoch 2
332        assert_eq!(packages["shadow-utils"].epoch, Some(2));
333        // perl-POSIX has explicit epoch 0
334        assert_eq!(packages["perl-POSIX"].epoch, Some(0));
335    }
336
337    #[test]
338    fn test_load_from_reader() {
339        let packages = load_from_reader(FIXTURE.as_bytes()).expect("failed to load packages");
340        assert!(!packages.is_empty(), "expected at least one package");
341        assert!(packages.get("rpm").is_some());
342    }
343
344    #[test]
345    fn test_file_parsing() {
346        let packages = load_from_str(FIXTURE).expect("failed to load packages");
347        let bash = packages.get("bash").expect("bash package not found");
348
349        // bash should have files
350        assert!(!bash.files.is_empty(), "bash should have files");
351
352        // Check /usr/bin/bash exists
353        let bash_bin = bash
354            .files
355            .get(Utf8Path::new("/usr/bin/bash"))
356            .expect("/usr/bin/bash not found");
357        assert!(bash_bin.size > 0, "bash binary should have non-zero size");
358        assert!(bash_bin.digest.is_some(), "bash binary should have digest");
359        assert_eq!(bash.digest_algo, Some(DigestAlgorithm::Sha256));
360        assert!(
361            !bash_bin.flags.is_config(),
362            "bash binary is not a config file"
363        );
364        assert_eq!(bash_bin.user, "root");
365        assert_eq!(bash_bin.group, "root");
366
367        // Check a config file
368        let bashrc = bash
369            .files
370            .get(Utf8Path::new("/etc/skel/.bashrc"))
371            .expect("/etc/skel/.bashrc not found");
372        assert!(bashrc.flags.is_config(), ".bashrc should be a config file");
373        assert!(bashrc.flags.is_noreplace(), ".bashrc should be noreplace");
374
375        // Check symlink /usr/bin/sh -> bash
376        let sh = bash
377            .files
378            .get(Utf8Path::new("/usr/bin/sh"))
379            .expect("/usr/bin/sh not found");
380        assert!(sh.linkto.is_some(), "/usr/bin/sh should be a symlink");
381        assert_eq!(sh.linkto.as_ref().unwrap(), "bash");
382
383        // Check ghost files from setup package
384        let setup = packages.get("setup").expect("setup package not found");
385
386        // /run/motd is a pure ghost file (flag=64)
387        let motd = setup
388            .files
389            .get(Utf8Path::new("/run/motd"))
390            .expect("/run/motd not found");
391        assert!(motd.flags.is_ghost(), "/run/motd should be a ghost");
392        assert!(!motd.flags.is_config(), "/run/motd is not a config file");
393        assert!(motd.digest.is_none(), "ghost files have no digest");
394
395        // /etc/fstab is ghost+config+missingok+noreplace (flag=89)
396        let fstab = setup
397            .files
398            .get(Utf8Path::new("/etc/fstab"))
399            .expect("/etc/fstab not found");
400        assert!(fstab.flags.is_ghost(), "/etc/fstab should be a ghost");
401        assert!(
402            fstab.flags.is_config(),
403            "/etc/fstab should be a config file"
404        );
405        assert!(fstab.flags.is_missingok(), "/etc/fstab should be missingok");
406        assert!(fstab.flags.is_noreplace(), "/etc/fstab should be noreplace");
407    }
408
409    #[test]
410    fn test_directory_ownership() {
411        // Test that files can be owned by a different package than the directory they reside in.
412        // In this fixture:
413        // - rpm owns /usr/lib/rpm/macros.d/ (the directory)
414        // - fedora-release-common owns /usr/lib/rpm/macros.d/macros.dist (a file in that directory)
415        let packages = load_from_str(FIXTURE).expect("failed to load packages");
416
417        let rpm = packages.get("rpm").expect("rpm package not found");
418        let fedora_release = packages
419            .get("fedora-release-common")
420            .expect("fedora-release-common package not found");
421
422        // Verify rpm owns the macros.d directory
423        let macros_d = rpm
424            .files
425            .get(Utf8Path::new("/usr/lib/rpm/macros.d"))
426            .expect("/usr/lib/rpm/macros.d not found in rpm");
427        // Directory mode: 0o40755 = 16877
428        assert_eq!(
429            macros_d.mode & 0o170000,
430            0o040000,
431            "macros.d should be a directory"
432        );
433
434        // Verify fedora-release-common owns macros.dist file
435        assert!(
436            fedora_release
437                .files
438                .contains_key(Utf8Path::new("/usr/lib/rpm/macros.d/macros.dist")),
439            "/usr/lib/rpm/macros.d/macros.dist not found in fedora-release-common"
440        );
441
442        // Verify the file is NOT in rpm's file list
443        assert!(
444            rpm.files
445                .get(Utf8Path::new("/usr/lib/rpm/macros.d/macros.dist"))
446                .is_none(),
447            "macros.dist should not be owned by rpm"
448        );
449
450        // Verify the directory is NOT in fedora-release-common's file list
451        assert!(
452            fedora_release
453                .files
454                .get(Utf8Path::new("/usr/lib/rpm/macros.d"))
455                .is_none(),
456            "macros.d directory should not be owned by fedora-release-common"
457        );
458    }
459
460    #[test]
461    fn test_single_file_scalar_values() {
462        // Test that single-file packages are parsed correctly.
463        let packages = load_from_str(FIXTURE).expect("failed to load packages");
464        let pkg = packages
465            .get("langpacks-core-en")
466            .expect("langpacks-core-en package not found");
467
468        assert_eq!(pkg.name, "langpacks-core-en");
469        assert_eq!(pkg.version, "4.2");
470        assert_eq!(pkg.release, "5.fc43");
471        assert_eq!(pkg.files.len(), 1);
472
473        let file = pkg
474            .files
475            .get(Utf8Path::new(
476                "/usr/share/metainfo/org.fedoraproject.LangPack-Core-en.metainfo.xml",
477            ))
478            .expect("metainfo.xml not found");
479        assert_eq!(file.size, 398);
480        assert_eq!(file.user, "root");
481        assert_eq!(file.group, "root");
482        assert_eq!(
483            file.digest.as_deref(),
484            Some("d0ba061c715c73b91d2be66ab40adfab510ed4e69cf5d40970733e211de38ce6")
485        );
486    }
487
488    #[test]
489    fn test_changelog_times() {
490        let packages = load_from_str(FIXTURE).expect("failed to load packages");
491
492        // bash package should have multiple changelog entries
493        let bash = packages.get("bash").expect("bash package not found");
494        assert!(
495            !bash.changelog_times.is_empty(),
496            "bash should have changelog entries"
497        );
498
499        // Verify changelog times are reasonable Unix timestamps (after 2020)
500        let min_valid_time = 1577836800u64; // 2020-01-01
501        for &time in &bash.changelog_times {
502            assert!(time > min_valid_time, "changelog time {} is too old", time);
503        }
504    }
505}