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