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