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