Skip to main content

provenant/parsers/
rpm_db.rs

1//! Parser for RPM database files.
2//!
3//! Extracts installed package metadata from the RPM database maintained by the
4//! system package manager, typically located in /var/lib/rpm/.
5//!
6//! # Supported Formats
7//! - /var/lib/rpm/Packages (BerkleyDB format or SQLite - raw database file)
8//! - Other RPM database index files
9//!
10//! # Key Features
11//! - Installed package metadata extraction from system RPM database
12//! - Database format detection (BDB vs SQLite)
13//! - Multi-version package support
14//! - Package URL (purl) generation with architecture namespace
15//!
16//! # Implementation Notes
17//! - Database location detection (/var/lib/rpm/Packages or variants)
18//! - Graceful error handling for unreadable or corrupted databases
19//! - Returns package data for each installed package entry
20
21use std::path::Path;
22use std::process::Command;
23
24use crate::parser_warn as warn;
25
26use crate::models::{DatasourceId, PackageData, PackageType};
27use crate::models::{Dependency, FileReference};
28
29use super::PackageParser;
30use super::rpm_db_native::{InstalledRpmDbKind, InstalledRpmPackage, read_installed_rpm_packages};
31use super::rpm_parser::infer_rpm_namespace;
32use super::rpm_parser::infer_rpm_namespace_from_filename;
33
34const PACKAGE_TYPE: PackageType = PackageType::Rpm;
35const RPM_QUERY_FORMAT: &str = concat!(
36    "__PKG__\n",
37    "name:%{NAME}\n",
38    "epoch:%{EPOCH}\n",
39    "version:%{VERSION}\n",
40    "release:%{RELEASE}\n",
41    "vendor:%{VENDOR}\n",
42    "distribution:%{DISTRIBUTION}\n",
43    "arch:%{ARCH}\n",
44    "platform:%{PLATFORM}\n",
45    "license:%{LICENSE}\n",
46    "source_rpm:%{SOURCERPM}\n",
47    "size:%{SIZE}\n",
48    "__REQUIRES__\n",
49    "[%{REQUIRENAME}\n]",
50    "__FILES__\n",
51    "[%{FILENAMES}\n]",
52    "__END__\n"
53);
54const PKG_MARKER: &str = "__PKG__";
55const REQUIRES_MARKER: &str = "__REQUIRES__";
56const FILES_MARKER: &str = "__FILES__";
57const END_MARKER: &str = "__END__";
58
59#[derive(Debug)]
60struct RpmQueryPackage {
61    name: Option<String>,
62    epoch: Option<String>,
63    version: Option<String>,
64    release: Option<String>,
65    vendor: Option<String>,
66    distribution: Option<String>,
67    arch: Option<String>,
68    platform: Option<String>,
69    size: Option<u64>,
70    license: Option<String>,
71    source_rpm: Option<String>,
72    requires: Vec<String>,
73    file_names: Vec<Option<String>>,
74    dir_indexes: Vec<i32>,
75    base_names: Vec<Option<String>>,
76    dir_names: Vec<String>,
77}
78
79fn default_package_data(datasource_id: DatasourceId) -> PackageData {
80    PackageData {
81        package_type: Some(PACKAGE_TYPE),
82        datasource_id: Some(datasource_id),
83        ..Default::default()
84    }
85}
86
87pub struct RpmBdbDatabaseParser;
88
89impl PackageParser for RpmBdbDatabaseParser {
90    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
91
92    fn is_match(path: &Path) -> bool {
93        let path_str = path.to_string_lossy();
94        (path_str.ends_with("/Packages") || path_str.contains("/var/lib/rpm/Packages"))
95            && !path_str.ends_with(".db")
96    }
97
98    fn extract_packages(path: &Path) -> Vec<PackageData> {
99        match parse_rpm_database(path, DatasourceId::RpmInstalledDatabaseBdb) {
100            Ok(pkgs) if !pkgs.is_empty() => pkgs,
101            Ok(_) => vec![default_package_data(DatasourceId::RpmInstalledDatabaseBdb)],
102            Err(e) => {
103                warn!("Failed to parse RPM BDB database {:?}: {}", path, e);
104                vec![default_package_data(DatasourceId::RpmInstalledDatabaseBdb)]
105            }
106        }
107    }
108}
109
110pub struct RpmNdbDatabaseParser;
111
112impl PackageParser for RpmNdbDatabaseParser {
113    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
114
115    fn is_match(path: &Path) -> bool {
116        let path_str = path.to_string_lossy();
117        path_str.ends_with("/Packages.db") || path_str.contains("usr/lib/sysimage/rpm/Packages.db")
118    }
119
120    fn extract_packages(path: &Path) -> Vec<PackageData> {
121        match parse_rpm_database(path, DatasourceId::RpmInstalledDatabaseNdb) {
122            Ok(pkgs) if !pkgs.is_empty() => pkgs,
123            Ok(_) => vec![default_package_data(DatasourceId::RpmInstalledDatabaseNdb)],
124            Err(e) => {
125                warn!("Failed to parse RPM NDB database {:?}: {}", path, e);
126                vec![default_package_data(DatasourceId::RpmInstalledDatabaseNdb)]
127            }
128        }
129    }
130}
131
132pub struct RpmSqliteDatabaseParser;
133
134impl PackageParser for RpmSqliteDatabaseParser {
135    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
136
137    fn is_match(path: &Path) -> bool {
138        let path_str = path.to_string_lossy();
139        path_str.ends_with("/rpmdb.sqlite") || path_str.contains("rpm/rpmdb.sqlite")
140    }
141
142    fn extract_packages(path: &Path) -> Vec<PackageData> {
143        match parse_rpm_database(path, DatasourceId::RpmInstalledDatabaseSqlite) {
144            Ok(pkgs) if !pkgs.is_empty() => pkgs,
145            Ok(_) => vec![default_package_data(
146                DatasourceId::RpmInstalledDatabaseSqlite,
147            )],
148            Err(e) => {
149                warn!("Failed to parse RPM SQLite database {:?}: {}", path, e);
150                vec![default_package_data(
151                    DatasourceId::RpmInstalledDatabaseSqlite,
152                )]
153            }
154        }
155    }
156}
157
158fn parse_rpm_database(
159    path: &Path,
160    datasource_id: DatasourceId,
161) -> Result<Vec<PackageData>, String> {
162    let native_kind = native_kind_for_datasource(datasource_id);
163    match read_installed_rpm_packages(path, native_kind) {
164        Ok(packages) => Ok(packages
165            .into_iter()
166            .map(native_package_to_query_package)
167            .map(|pkg| build_package_data(pkg, datasource_id))
168            .collect()),
169        Err(native_error) if !rpm_command_available() => Err(format!(
170            "native installed RPM reader failed for {:?}: {}",
171            path, native_error
172        )),
173        Err(native_error) => {
174            let rpmdb_dir = path
175                .parent()
176                .ok_or_else(|| format!("RPM database path {:?} has no parent directory", path))?;
177
178            query_rpm_database(rpmdb_dir)
179                .map(|packages| {
180                    packages
181                        .into_iter()
182                        .map(|pkg| build_package_data(pkg, datasource_id))
183                        .collect()
184                })
185                .map_err(|fallback_error| {
186                    format!(
187                        "native installed RPM reader failed for {:?}: {}; rpm CLI fallback failed: {}",
188                        path, native_error, fallback_error
189                    )
190                })
191        }
192    }
193}
194
195fn native_kind_for_datasource(datasource_id: DatasourceId) -> InstalledRpmDbKind {
196    match datasource_id {
197        DatasourceId::RpmInstalledDatabaseBdb => InstalledRpmDbKind::Bdb,
198        DatasourceId::RpmInstalledDatabaseNdb => InstalledRpmDbKind::Ndb,
199        DatasourceId::RpmInstalledDatabaseSqlite => InstalledRpmDbKind::Sqlite,
200        other => panic!("unexpected datasource for installed RPM DB: {other:?}"),
201    }
202}
203
204fn native_package_to_query_package(package: InstalledRpmPackage) -> RpmQueryPackage {
205    RpmQueryPackage {
206        name: normalize_optional_string(Some(package.name)),
207        epoch: Some(package.epoch.to_string()),
208        version: normalize_optional_string(Some(package.version)),
209        release: normalize_optional_string(Some(package.release)),
210        vendor: normalize_optional_string(Some(package.vendor)),
211        distribution: normalize_optional_string(Some(package.distribution)),
212        arch: normalize_optional_string(Some(package.arch)),
213        platform: normalize_optional_string(Some(package.platform)),
214        size: (package.size > 0).then_some(package.size as u64),
215        license: normalize_optional_string(Some(package.license)),
216        source_rpm: normalize_optional_string(Some(package.source_rpm)),
217        requires: package.requires,
218        file_names: package.file_names.into_iter().map(Some).collect(),
219        dir_indexes: package.dir_indexes,
220        base_names: package.base_names.into_iter().map(Some).collect(),
221        dir_names: package.dir_names,
222    }
223}
224
225fn build_evr_version(epoch: i32, version: &str, release: &str) -> Option<String> {
226    if version.is_empty() {
227        return None;
228    }
229
230    let mut evr = String::new();
231
232    if epoch > 0 {
233        evr.push_str(&format!("{}:", epoch));
234    }
235
236    evr.push_str(version);
237
238    if !release.is_empty() {
239        evr.push('-');
240        evr.push_str(release);
241    }
242
243    Some(evr)
244}
245
246fn build_file_references(
247    base_names: &[Option<String>],
248    dir_indexes: &[i32],
249    dir_names: &[String],
250) -> Vec<FileReference> {
251    if base_names.is_empty() || dir_names.is_empty() {
252        return Vec::new();
253    }
254
255    base_names
256        .iter()
257        .zip(dir_indexes.iter())
258        .filter_map(|(basename, &dir_idx)| {
259            let dirname = dir_names.get(dir_idx as usize)?;
260            let basename = basename.as_deref().unwrap_or_default();
261            let path = format!("{}{}", dirname, basename);
262            if path.is_empty() || path == "/" {
263                return None;
264            }
265            Some(FileReference {
266                path,
267                size: None,
268                sha1: None,
269                md5: None,
270                sha256: None,
271                sha512: None,
272                extra_data: None,
273            })
274        })
275        .collect()
276}
277
278fn build_file_references_from_paths(paths: &[Option<String>]) -> Vec<FileReference> {
279    paths
280        .iter()
281        .filter_map(|path| {
282            let path = path.as_deref()?.trim();
283            if path.is_empty() || path == "/" {
284                return None;
285            }
286
287            Some(FileReference {
288                path: path.to_string(),
289                size: None,
290                sha1: None,
291                md5: None,
292                sha256: None,
293                sha512: None,
294                extra_data: None,
295            })
296        })
297        .collect()
298}
299
300fn query_rpm_database(rpmdb_dir: &Path) -> Result<Vec<RpmQueryPackage>, String> {
301    let rpmdb_dir = rpmdb_dir.canonicalize().map_err(|e| {
302        format!(
303            "Failed to resolve RPM database directory {:?} to an absolute path: {}",
304            rpmdb_dir, e
305        )
306    })?;
307
308    let output = Command::new("rpm")
309        .args(["--dbpath"])
310        .arg(&rpmdb_dir)
311        .args(["--query", "--all", "--queryformat", RPM_QUERY_FORMAT])
312        .output()
313        .map_err(|e| format!("Failed to execute rpm for {:?}: {}", rpmdb_dir, e))?;
314
315    if !output.status.success() {
316        let stderr = String::from_utf8_lossy(&output.stderr);
317        let stdout = String::from_utf8_lossy(&output.stdout);
318        let details = if !stderr.trim().is_empty() {
319            stderr.trim().to_string()
320        } else {
321            stdout.trim().to_string()
322        };
323        return Err(format!(
324            "rpm query failed for {:?} (status: {}): {}",
325            rpmdb_dir, output.status, details
326        ));
327    }
328
329    let stdout = String::from_utf8(output.stdout)
330        .map_err(|e| format!("rpm output for {:?} was not valid UTF-8: {}", rpmdb_dir, e))?;
331
332    parse_rpm_query_output(&stdout)
333}
334
335fn rpm_command_available() -> bool {
336    Command::new("rpm").arg("--version").output().is_ok()
337}
338
339fn parse_rpm_query_output(stdout: &str) -> Result<Vec<RpmQueryPackage>, String> {
340    let mut packages = Vec::new();
341
342    for block in stdout
343        .split(PKG_MARKER)
344        .filter(|block| !block.trim().is_empty())
345    {
346        let mut package = RpmQueryPackage {
347            name: None,
348            epoch: None,
349            version: None,
350            release: None,
351            vendor: None,
352            distribution: None,
353            arch: None,
354            platform: None,
355            size: None,
356            license: None,
357            source_rpm: None,
358            requires: Vec::new(),
359            file_names: Vec::new(),
360            dir_indexes: Vec::new(),
361            base_names: Vec::new(),
362            dir_names: Vec::new(),
363        };
364
365        enum Section {
366            Scalars,
367            Requires,
368            Files,
369        }
370
371        let mut section = Section::Scalars;
372
373        for line in block.lines() {
374            let line = line.trim();
375            if line.is_empty() {
376                continue;
377            }
378
379            if line == REQUIRES_MARKER {
380                section = Section::Requires;
381                continue;
382            }
383            if line == FILES_MARKER {
384                section = Section::Files;
385                continue;
386            }
387            if line == END_MARKER {
388                break;
389            }
390
391            match section {
392                Section::Scalars => {
393                    let Some((key, value)) = line.split_once(':') else {
394                        return Err(format!(
395                            "Failed to parse rpm queryformat scalar line: {line}"
396                        ));
397                    };
398
399                    match key {
400                        "name" => package.name = Some(value.to_string()),
401                        "epoch" => package.epoch = Some(value.to_string()),
402                        "version" => package.version = Some(value.to_string()),
403                        "release" => package.release = Some(value.to_string()),
404                        "vendor" => package.vendor = Some(value.to_string()),
405                        "distribution" => package.distribution = Some(value.to_string()),
406                        "arch" => package.arch = Some(value.to_string()),
407                        "platform" => package.platform = Some(value.to_string()),
408                        "license" => package.license = Some(value.to_string()),
409                        "source_rpm" => package.source_rpm = Some(value.to_string()),
410                        "size" => package.size = value.parse::<u64>().ok(),
411                        _ => {}
412                    }
413                }
414                Section::Requires => package.requires.push(line.to_string()),
415                Section::Files => package.file_names.push(Some(line.to_string())),
416            }
417        }
418
419        packages.push(package);
420    }
421
422    Ok(packages)
423}
424
425fn build_package_data(pkg: RpmQueryPackage, datasource_id: DatasourceId) -> PackageData {
426    let name = normalize_optional_string(pkg.name);
427    let version_raw = normalize_optional_string(pkg.version);
428    let release = normalize_optional_string(pkg.release);
429    let version = build_evr_version(
430        parse_epoch(pkg.epoch),
431        version_raw.as_deref().unwrap_or_default(),
432        release.as_deref().unwrap_or_default(),
433    );
434
435    let vendor = normalize_optional_string(pkg.vendor)
436        .or_else(|| normalize_optional_string(pkg.distribution));
437    let source_rpm = normalize_optional_string(pkg.source_rpm);
438    let namespace =
439        infer_rpm_namespace(None, vendor.as_deref(), release.as_deref(), None).or_else(|| {
440            source_rpm
441                .as_deref()
442                .and_then(|source_rpm| infer_rpm_namespace_from_filename(Path::new(source_rpm)))
443        });
444
445    let architecture = normalize_optional_string(pkg.arch)
446        .or_else(|| infer_platform_architecture(pkg.platform.as_deref()));
447    let dependencies = pkg
448        .requires
449        .into_iter()
450        .filter_map(|require| build_dependency(&require))
451        .collect();
452    let extracted_license_statement = normalize_optional_string(pkg.license);
453    let source_packages = source_rpm.clone().into_iter().collect();
454    let file_references = {
455        let from_dir_components =
456            build_file_references(&pkg.base_names, &pkg.dir_indexes, &pkg.dir_names);
457        if from_dir_components.is_empty() {
458            build_file_references_from_paths(&pkg.file_names)
459        } else {
460            from_dir_components
461        }
462    };
463    let purl = build_package_purl(
464        name.as_deref(),
465        namespace.as_deref(),
466        version.as_deref(),
467        architecture.as_deref(),
468    );
469
470    PackageData {
471        datasource_id: Some(datasource_id),
472        package_type: Some(PACKAGE_TYPE),
473        namespace,
474        name,
475        version,
476        qualifiers: architecture.as_ref().map(|arch| {
477            let mut q = std::collections::HashMap::new();
478            q.insert("arch".to_string(), arch.clone());
479            q
480        }),
481        subpath: None,
482        primary_language: None,
483        description: None,
484        release_date: None,
485        parties: Vec::new(),
486        keywords: Vec::new(),
487        homepage_url: None,
488        download_url: None,
489        size: pkg.size.filter(|size| *size > 0),
490        sha1: None,
491        md5: None,
492        sha256: None,
493        sha512: None,
494        bug_tracking_url: None,
495        code_view_url: None,
496        vcs_url: None,
497        copyright: None,
498        holder: None,
499        declared_license_expression: None,
500        declared_license_expression_spdx: None,
501        license_detections: Vec::new(),
502        other_license_expression: None,
503        other_license_expression_spdx: None,
504        other_license_detections: Vec::new(),
505        extracted_license_statement,
506        notice_text: None,
507        source_packages,
508        file_references,
509        is_private: false,
510        is_virtual: false,
511        extra_data: None,
512        dependencies,
513        repository_homepage_url: None,
514        repository_download_url: None,
515        api_data_url: None,
516        purl,
517    }
518}
519
520fn build_dependency(require: &str) -> Option<Dependency> {
521    let require = require.trim();
522    if require.is_empty() || require.starts_with("rpmlib(") || require.starts_with("config(") {
523        return None;
524    }
525
526    let purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), require)
527        .ok()
528        .map(|p| p.to_string());
529
530    Some(Dependency {
531        purl,
532        extracted_requirement: None,
533        scope: Some("requires".to_string()),
534        is_runtime: Some(true),
535        is_optional: Some(false),
536        is_pinned: Some(false),
537        is_direct: Some(true),
538        resolved_package: None,
539        extra_data: None,
540    })
541}
542
543fn build_package_purl(
544    name: Option<&str>,
545    namespace: Option<&str>,
546    version: Option<&str>,
547    arch: Option<&str>,
548) -> Option<String> {
549    let name = name?;
550    let mut purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
551
552    if let Some(namespace) = namespace {
553        purl.with_namespace(namespace).ok()?;
554    }
555
556    if let Some(version) = version {
557        purl.with_version(version).ok()?;
558    }
559
560    if let Some(arch) = arch {
561        purl.add_qualifier("arch", arch).ok()?;
562    }
563
564    Some(purl.to_string())
565}
566
567fn normalize_optional_string(value: Option<String>) -> Option<String> {
568    value.and_then(|value| {
569        let trimmed = value.trim();
570        if trimmed.is_empty() || trimmed == "(none)" || trimmed == "[]" {
571            None
572        } else {
573            Some(trimmed.to_string())
574        }
575    })
576}
577
578fn parse_epoch(value: Option<String>) -> i32 {
579    normalize_optional_string(value)
580        .and_then(|value| value.parse::<i32>().ok())
581        .unwrap_or(0)
582}
583
584fn infer_platform_architecture(platform: Option<&str>) -> Option<String> {
585    let platform = platform?.trim();
586    if platform.is_empty() {
587        return None;
588    }
589
590    platform
591        .split_once('-')
592        .map(|(arch, _)| arch)
593        .filter(|arch| !arch.is_empty())
594        .map(|arch| arch.to_string())
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    use crate::models::DatasourceId;
602    use std::path::PathBuf;
603
604    #[test]
605    fn test_bdb_parser_is_match() {
606        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
607            "/var/lib/rpm/Packages"
608        )));
609        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
610            "rootfs/var/lib/rpm/Packages"
611        )));
612        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
613            "/var/lib/rpm/Packages.db"
614        )));
615    }
616
617    #[test]
618    fn test_ndb_parser_is_match() {
619        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
620            "usr/lib/sysimage/rpm/Packages.db"
621        )));
622        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
623            "/rootfs/usr/lib/sysimage/rpm/Packages.db"
624        )));
625        assert!(!RpmNdbDatabaseParser::is_match(&PathBuf::from(
626            "usr/lib/rpm/Packages"
627        )));
628    }
629
630    #[test]
631    fn test_sqlite_parser_is_match() {
632        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
633            "var/lib/rpm/rpmdb.sqlite"
634        )));
635        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
636            "/rootfs/var/lib/rpm/rpmdb.sqlite"
637        )));
638        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
639            "/var/lib/rpm/Packages"
640        )));
641    }
642
643    #[test]
644    fn test_build_evr_version_full() {
645        assert_eq!(
646            build_evr_version(2, "1.0.0", "1.el7"),
647            Some("2:1.0.0-1.el7".to_string())
648        );
649    }
650
651    #[test]
652    fn test_build_evr_version_no_epoch() {
653        assert_eq!(
654            build_evr_version(0, "1.0.0", "1.el7"),
655            Some("1.0.0-1.el7".to_string())
656        );
657    }
658
659    #[test]
660    fn test_build_evr_version_no_release() {
661        assert_eq!(build_evr_version(0, "1.0.0", ""), Some("1.0.0".to_string()));
662    }
663
664    #[test]
665    fn test_build_evr_version_empty() {
666        assert_eq!(build_evr_version(0, "", ""), None);
667    }
668
669    #[test]
670    fn test_parse_rpm_database_sqlite() {
671        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
672
673        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
674
675        assert_eq!(pkg.package_type, Some(PackageType::Rpm));
676        assert_eq!(
677            pkg.datasource_id,
678            Some(DatasourceId::RpmInstalledDatabaseSqlite)
679        );
680        assert!(pkg.name.is_some());
681    }
682
683    #[test]
684    fn test_parse_rpm_database_sqlite_preserves_release_in_version() {
685        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
686
687        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
688
689        assert!(
690            pkg.version
691                .as_ref()
692                .is_some_and(|version| version.contains('-'))
693        );
694    }
695
696    #[test]
697    fn test_build_file_references_skips_invalid_entries() {
698        let file_refs = build_file_references(
699            &[
700                Some("valid".to_string()),
701                Some("".to_string()),
702                Some("ignored".to_string()),
703            ],
704            &[0, 0, -1],
705            &["/usr/bin/".to_string()],
706        );
707
708        assert_eq!(file_refs.len(), 2);
709        assert_eq!(file_refs[0].path, "/usr/bin/valid");
710        assert_eq!(file_refs[1].path, "/usr/bin/");
711    }
712
713    #[test]
714    fn test_build_package_data_falls_back_to_file_names() {
715        let package = build_package_data(
716            RpmQueryPackage {
717                name: Some("libgcc".to_string()),
718                epoch: None,
719                version: Some("13.1.1".to_string()),
720                release: Some("2.fc38".to_string()),
721                vendor: Some("Fedora Project".to_string()),
722                distribution: None,
723                arch: Some("x86_64".to_string()),
724                platform: None,
725                size: Some(235748),
726                license: Some("GPLv3+".to_string()),
727                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
728                requires: Vec::new(),
729                file_names: vec![
730                    Some("/usr/share/licenses/libgcc/COPYING".to_string()),
731                    Some("/usr/share/licenses/libgcc/COPYING.RUNTIME".to_string()),
732                ],
733                dir_indexes: Vec::new(),
734                base_names: Vec::new(),
735                dir_names: Vec::new(),
736            },
737            DatasourceId::RpmInstalledDatabaseSqlite,
738        );
739
740        assert_eq!(package.file_references.len(), 2);
741        assert_eq!(
742            package.file_references[0].path,
743            "/usr/share/licenses/libgcc/COPYING"
744        );
745        assert_eq!(
746            package.file_references[1].path,
747            "/usr/share/licenses/libgcc/COPYING.RUNTIME"
748        );
749    }
750
751    #[test]
752    fn test_build_package_data_uses_distribution_for_namespace() {
753        let package = build_package_data(
754            RpmQueryPackage {
755                name: Some("libgcc".to_string()),
756                epoch: None,
757                version: Some("13.1.1".to_string()),
758                release: Some("2.fc38".to_string()),
759                vendor: None,
760                distribution: Some("Fedora Project".to_string()),
761                arch: Some("x86_64".to_string()),
762                platform: None,
763                size: Some(235748),
764                license: Some("GPLv3+".to_string()),
765                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
766                requires: Vec::new(),
767                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
768                dir_indexes: Vec::new(),
769                base_names: Vec::new(),
770                dir_names: Vec::new(),
771            },
772            DatasourceId::RpmInstalledDatabaseSqlite,
773        );
774
775        assert_eq!(package.namespace.as_deref(), Some("fedora"));
776    }
777
778    #[test]
779    fn test_build_package_data_uses_source_rpm_for_namespace() {
780        let package = build_package_data(
781            RpmQueryPackage {
782                name: Some("libgcc".to_string()),
783                epoch: None,
784                version: Some("13.1.1".to_string()),
785                release: None,
786                vendor: None,
787                distribution: None,
788                arch: Some("x86_64".to_string()),
789                platform: None,
790                size: Some(235748),
791                license: Some("GPLv3+".to_string()),
792                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
793                requires: Vec::new(),
794                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
795                dir_indexes: Vec::new(),
796                base_names: Vec::new(),
797                dir_names: Vec::new(),
798            },
799            DatasourceId::RpmInstalledDatabaseSqlite,
800        );
801
802        assert_eq!(package.namespace.as_deref(), Some("fedora"));
803    }
804
805    #[test]
806    fn test_build_package_data_uses_platform_for_architecture() {
807        let package = build_package_data(
808            RpmQueryPackage {
809                name: Some("libgcc".to_string()),
810                epoch: None,
811                version: Some("13.1.1".to_string()),
812                release: None,
813                vendor: None,
814                distribution: None,
815                arch: None,
816                platform: Some("x86_64-redhat-linux".to_string()),
817                size: Some(235748),
818                license: Some("GPLv3+".to_string()),
819                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
820                requires: Vec::new(),
821                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
822                dir_indexes: Vec::new(),
823                base_names: Vec::new(),
824                dir_names: Vec::new(),
825            },
826            DatasourceId::RpmInstalledDatabaseSqlite,
827        );
828
829        assert_eq!(
830            package.qualifiers.as_ref().and_then(|q| q.get("arch")),
831            Some(&"x86_64".to_string())
832        );
833    }
834
835    #[test]
836    fn test_parse_rpm_query_output_parses_queryformat_blocks() {
837        let stdout = r#"
838__PKG__
839name:libgcc
840epoch:(none)
841version:13.1.1
842release:2.fc38
843vendor:Fedora Project
844distribution:Fedora Project
845arch:x86_64
846platform:x86_64-redhat-linux
847license:GPLv3+
848source_rpm:gcc-13.1.1-2.fc38.src.rpm
849size:235748
850__REQUIRES__
851rpmlib(PayloadIsZstd)
852glibc
853__FILES__
854/usr/share/licenses/libgcc/COPYING
855__END__
856__PKG__
857name:coreutils
858epoch:(none)
859version:9.1
860release:12.fc38
861vendor:Fedora Project
862distribution:Fedora Project
863arch:x86_64
864platform:x86_64-redhat-linux-gnu
865license:GPLv3+
866source_rpm:coreutils-9.1-12.fc38.src.rpm
867size:5828674
868__REQUIRES__
869glibc
870__FILES__
871/usr/bin/cat
872__END__
873        "#;
874
875        let packages = parse_rpm_query_output(stdout).expect("rpm queryformat output should parse");
876
877        assert_eq!(packages.len(), 2);
878        assert_eq!(packages[0].name.as_deref(), Some("libgcc"));
879        assert_eq!(packages[0].file_names.len(), 1);
880        assert_eq!(packages[0].requires.len(), 2);
881        assert_eq!(packages[1].name.as_deref(), Some("coreutils"));
882        assert_eq!(packages[1].requires, vec!["glibc".to_string()]);
883    }
884}
885
886crate::register_parser!(
887    "RPM installed package database (requires `rpm` CLI at runtime)",
888    &[
889        "**/var/lib/rpm/Packages",
890        "**/var/lib/rpm/Packages.db",
891        "**/var/lib/rpm/rpmdb.sqlite"
892    ],
893    "rpm",
894    "",
895    Some("https://rpm.org/"),
896);