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