Skip to main content

provenant/parsers/
rpm_db.rs

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