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_MANIFEST_SIZE, capped_iteration_limit, 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) => {
226            let limit = capped_iteration_limit(packages.len(), "rpm_db native packages");
227            Ok(packages
228                .into_iter()
229                .take(limit)
230                .map(native_package_to_query_package)
231                .map(|pkg| build_package_data(pkg, datasource_id))
232                .collect())
233        }
234        Err(native_error) => Err(format!(
235            "native installed RPM reader failed for {:?}: {}",
236            path, native_error
237        )),
238    }
239}
240
241fn path_matches_suffix(path: &Path, suffix: &str) -> bool {
242    path.to_string_lossy().replace('\\', "/").ends_with(suffix)
243}
244
245fn path_matches_any_suffix(path: &Path, suffixes: &[&str]) -> bool {
246    suffixes
247        .iter()
248        .any(|suffix| path_matches_suffix(path, suffix))
249}
250
251fn native_kind_for_datasource(datasource_id: DatasourceId) -> Result<InstalledRpmDbKind, String> {
252    match datasource_id {
253        DatasourceId::RpmInstalledDatabaseBdb => Ok(InstalledRpmDbKind::Bdb),
254        DatasourceId::RpmInstalledDatabaseNdb => Ok(InstalledRpmDbKind::Ndb),
255        DatasourceId::RpmInstalledDatabaseSqlite => Ok(InstalledRpmDbKind::Sqlite),
256        other => Err(format!(
257            "unexpected datasource for installed RPM DB: {other:?}"
258        )),
259    }
260}
261
262fn native_package_to_query_package(package: InstalledRpmPackage) -> RpmQueryPackage {
263    let requires_limit = capped_iteration_limit(package.requires.len(), "rpm_db package requires");
264    let file_names_limit =
265        capped_iteration_limit(package.file_names.len(), "rpm_db package file_names");
266    let base_names_limit =
267        capped_iteration_limit(package.base_names.len(), "rpm_db package base_names");
268    let dir_names_limit =
269        capped_iteration_limit(package.dir_names.len(), "rpm_db package dir_names");
270    RpmQueryPackage {
271        name: truncate_optional_string(Some(package.name)),
272        epoch: Some(package.epoch.to_string()),
273        version: truncate_optional_string(Some(package.version)),
274        release: truncate_optional_string(Some(package.release)),
275        vendor: truncate_optional_string(Some(package.vendor)),
276        distribution: truncate_optional_string(Some(package.distribution)),
277        arch: truncate_optional_string(Some(package.arch)),
278        platform: truncate_optional_string(Some(package.platform)),
279        size: (package.size > 0).then_some(u64::from(package.size)),
280        license: truncate_optional_string(Some(package.license)),
281        source_rpm: truncate_optional_string(Some(package.source_rpm)),
282        requires: package
283            .requires
284            .into_iter()
285            .take(requires_limit)
286            .map(truncate_field)
287            .collect(),
288        file_names: package
289            .file_names
290            .into_iter()
291            .take(file_names_limit)
292            .map(|s| Some(truncate_field(s)))
293            .collect(),
294        dir_indexes: package.dir_indexes,
295        base_names: package
296            .base_names
297            .into_iter()
298            .take(base_names_limit)
299            .map(|s| Some(truncate_field(s)))
300            .collect(),
301        dir_names: package
302            .dir_names
303            .into_iter()
304            .take(dir_names_limit)
305            .map(truncate_field)
306            .collect(),
307    }
308}
309
310fn truncate_optional_string(value: Option<String>) -> Option<String> {
311    value
312        .map(truncate_field)
313        .and_then(|v| normalize_optional_string(Some(v)))
314}
315
316fn build_version_release(version: &str, release: &str) -> Option<String> {
317    if version.is_empty() {
318        return None;
319    }
320
321    let mut vr = String::from(version);
322
323    if !release.is_empty() {
324        vr.push('-');
325        vr.push_str(release);
326    }
327
328    Some(vr)
329}
330
331fn build_file_references(
332    base_names: &[Option<String>],
333    dir_indexes: &[u32],
334    dir_names: &[String],
335) -> Vec<FileReference> {
336    if base_names.is_empty() || dir_names.is_empty() {
337        return Vec::new();
338    }
339
340    let limit = capped_iteration_limit(
341        base_names.len().min(dir_indexes.len()),
342        "rpm_db file references",
343    );
344    base_names
345        .iter()
346        .zip(dir_indexes.iter())
347        .take(limit)
348        .filter_map(|(basename, &dir_idx)| {
349            let dirname = dir_names.get(dir_idx as usize)?;
350            let basename = basename.as_deref().unwrap_or_default();
351            let path = format!("{}{}", dirname, basename);
352            if path.is_empty() || path == "/" {
353                return None;
354            }
355            Some(FileReference {
356                path,
357                size: None,
358                sha1: None,
359                md5: None,
360                sha256: None,
361                sha512: None,
362                extra_data: None,
363            })
364        })
365        .collect()
366}
367
368fn build_file_references_from_paths(paths: &[Option<String>]) -> Vec<FileReference> {
369    let limit = capped_iteration_limit(paths.len(), "rpm_db file references from paths");
370    paths
371        .iter()
372        .take(limit)
373        .filter_map(|path| {
374            let path = path.as_deref()?.trim();
375            if path.is_empty() || path == "/" {
376                return None;
377            }
378
379            Some(FileReference {
380                path: path.to_string(),
381                size: None,
382                sha1: None,
383                md5: None,
384                sha256: None,
385                sha512: None,
386                extra_data: None,
387            })
388        })
389        .collect()
390}
391
392fn build_package_data(pkg: RpmQueryPackage, datasource_id: DatasourceId) -> PackageData {
393    let name = normalize_optional_string(pkg.name).map(truncate_field);
394    let version_raw = normalize_optional_string(pkg.version).map(truncate_field);
395    let release = normalize_optional_string(pkg.release).map(truncate_field);
396    let version = build_version_release(
397        version_raw.as_deref().unwrap_or_default(),
398        release.as_deref().unwrap_or_default(),
399    );
400    // The rpm epoch is emitted as an `?epoch=` qualifier rather than folded
401    // into the version (e.g. `2:1.0-1`).
402    let epoch_value = parse_epoch(pkg.epoch);
403    let epoch = (epoch_value > 0).then(|| epoch_value.to_string());
404
405    let vendor = normalize_optional_string(pkg.vendor)
406        .map(truncate_field)
407        .or_else(|| normalize_optional_string(pkg.distribution).map(truncate_field));
408    let source_rpm = normalize_optional_string(pkg.source_rpm).map(truncate_field);
409    let namespace =
410        infer_rpm_namespace(None, vendor.as_deref(), release.as_deref(), None).or_else(|| {
411            source_rpm
412                .as_deref()
413                .and_then(|source_rpm| infer_rpm_namespace_from_filename(Path::new(source_rpm)))
414        });
415
416    let architecture = normalize_optional_string(pkg.arch)
417        .map(truncate_field)
418        .or_else(|| infer_platform_architecture(pkg.platform.as_deref()));
419    let requires_limit = capped_iteration_limit(pkg.requires.len(), "rpm_db package dependencies");
420    let dependencies = pkg
421        .requires
422        .into_iter()
423        .take(requires_limit)
424        .filter_map(|require| build_dependency(&require))
425        .collect();
426    let extracted_license_statement = normalize_optional_string(pkg.license).map(truncate_field);
427    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
428        extracted_license_statement
429            .as_deref()
430            .and_then(normalize_rpm_declared_license)
431            .map(|normalized| {
432                build_declared_license_data(
433                    normalized,
434                    DeclaredLicenseMatchMetadata::single_line(
435                        extracted_license_statement.as_deref().unwrap_or_default(),
436                    ),
437                )
438            })
439            .map(|(expr, spdx, detections)| {
440                (
441                    expr.map(truncate_field),
442                    spdx.map(truncate_field),
443                    detections,
444                )
445            })
446            .unwrap_or_else(empty_declared_license_data);
447    let source_packages = source_rpm.clone().into_iter().collect();
448    let file_references = {
449        let from_dir_components =
450            build_file_references(&pkg.base_names, &pkg.dir_indexes, &pkg.dir_names);
451        if from_dir_components.is_empty() {
452            build_file_references_from_paths(&pkg.file_names)
453        } else {
454            from_dir_components
455        }
456    };
457    let purl = build_package_purl(
458        name.as_deref(),
459        namespace.as_deref(),
460        version.as_deref(),
461        architecture.as_deref(),
462        epoch.as_deref(),
463    );
464
465    PackageData {
466        datasource_id: Some(datasource_id),
467        package_type: Some(PACKAGE_TYPE),
468        namespace,
469        name,
470        version,
471        qualifiers: {
472            let mut q = std::collections::HashMap::new();
473            if let Some(arch) = &architecture {
474                q.insert("arch".to_string(), arch.clone());
475            }
476            if let Some(epoch) = &epoch {
477                q.insert("epoch".to_string(), epoch.clone());
478            }
479            (!q.is_empty()).then_some(q)
480        },
481        subpath: None,
482        primary_language: None,
483        description: None,
484        release_date: None,
485        parties: Vec::new(),
486        keywords: Vec::new(),
487        homepage_url: None,
488        download_url: None,
489        size: pkg.size.filter(|size| *size > 0),
490        sha1: None,
491        md5: None,
492        sha256: None,
493        sha512: None,
494        bug_tracking_url: None,
495        code_view_url: None,
496        vcs_url: None,
497        copyright: None,
498        holder: None,
499        declared_license_expression,
500        declared_license_expression_spdx,
501        license_detections,
502        other_license_expression: None,
503        other_license_expression_spdx: None,
504        other_license_detections: Vec::new(),
505        extracted_license_statement,
506        notice_text: None,
507        source_packages,
508        file_references,
509        is_private: false,
510        is_virtual: false,
511        extra_data: None,
512        dependencies,
513        repository_homepage_url: None,
514        repository_download_url: None,
515        api_data_url: None,
516        purl,
517    }
518}
519
520fn build_dependency(require: &str) -> Option<Dependency> {
521    let require = require.trim();
522    if require.is_empty() || require.starts_with("rpmlib(") || require.starts_with("config(") {
523        return None;
524    }
525
526    let purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), require)
527        .ok()
528        .map(|p| p.to_string());
529
530    Some(Dependency {
531        purl,
532        extracted_requirement: None,
533        scope: Some("requires".to_string()),
534        is_runtime: Some(true),
535        is_optional: Some(false),
536        is_pinned: Some(false),
537        is_direct: Some(true),
538        resolved_package: None,
539        extra_data: None,
540    })
541}
542
543fn build_package_purl(
544    name: Option<&str>,
545    namespace: Option<&str>,
546    version: Option<&str>,
547    arch: Option<&str>,
548    epoch: Option<&str>,
549) -> Option<String> {
550    let name = name?;
551    let mut purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
552
553    if let Some(namespace) = namespace {
554        purl.with_namespace(namespace).ok()?;
555    }
556
557    if let Some(version) = version {
558        purl.with_version(version).ok()?;
559    }
560
561    if let Some(arch) = arch {
562        purl.add_qualifier("arch", arch).ok()?;
563    }
564
565    // The rpm epoch is a qualifier, not part of the version.
566    if let Some(epoch) = epoch {
567        purl.add_qualifier("epoch", epoch).ok()?;
568    }
569
570    Some(purl.to_string())
571}
572
573fn normalize_optional_string(value: Option<String>) -> Option<String> {
574    value.and_then(|value| {
575        let trimmed = value.trim();
576        if trimmed.is_empty() || trimmed == "(none)" || trimmed == "[]" {
577            None
578        } else {
579            Some(trimmed.to_string())
580        }
581    })
582}
583
584fn parse_epoch(value: Option<String>) -> u32 {
585    normalize_optional_string(value)
586        .and_then(|value| value.parse::<u32>().ok())
587        .unwrap_or(0)
588}
589
590fn infer_platform_architecture(platform: Option<&str>) -> Option<String> {
591    let platform = platform?.trim();
592    if platform.is_empty() {
593        return None;
594    }
595
596    platform
597        .split_once('-')
598        .map(|(arch, _)| arch)
599        .filter(|arch| !arch.is_empty())
600        .map(|arch| arch.to_string())
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    use crate::models::DatasourceId;
608    use std::path::PathBuf;
609
610    #[test]
611    fn test_bdb_parser_is_match() {
612        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
613            "/var/lib/rpm/Packages"
614        )));
615        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
616            "rootfs/var/lib/rpm/Packages"
617        )));
618        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
619            "/usr/lib/sysimage/rpm/Packages"
620        )));
621        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
622            "/var/lib/rpm/Packages.db"
623        )));
624        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
625            "lib/modules/datasource/deb/__fixtures__/Packages"
626        )));
627        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from("Packages")));
628        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
629            "testdata/rpm/var/lib/rpm/Packages.expected.json"
630        )));
631    }
632
633    #[test]
634    fn test_ndb_parser_is_match() {
635        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
636            "usr/lib/sysimage/rpm/Packages.db"
637        )));
638        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
639            "/rootfs/usr/lib/sysimage/rpm/Packages.db"
640        )));
641        assert!(!RpmNdbDatabaseParser::is_match(&PathBuf::from(
642            "usr/lib/rpm/Packages"
643        )));
644        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
645            "var/lib/rpm/Packages.db"
646        )));
647        assert!(!RpmNdbDatabaseParser::is_match(&PathBuf::from(
648            "testdata/rpm/usr/lib/sysimage/rpm/Packages.db.expected.json"
649        )));
650    }
651
652    #[cfg(feature = "rpm-sqlite")]
653    #[test]
654    fn test_sqlite_parser_is_match() {
655        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
656            "var/lib/rpm/rpmdb.sqlite"
657        )));
658        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
659            "/rootfs/var/lib/rpm/rpmdb.sqlite"
660        )));
661        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
662            "/rootfs/usr/lib/sysimage/rpm/rpmdb.sqlite"
663        )));
664        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
665            "/var/lib/rpm/Packages"
666        )));
667        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
668            "testdata/rpm/rpmdb.sqlite.expected.json"
669        )));
670        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
671            "testdata/rpm/rpmdb.sqlite-shm"
672        )));
673        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
674            "testdata/rpm/rpmdb.sqlite-wal"
675        )));
676    }
677
678    #[test]
679    fn test_build_version_release_full() {
680        // Epoch is no longer folded into the version; it is an `?epoch=` qualifier.
681        assert_eq!(
682            build_version_release("1.0.0", "1.el7"),
683            Some("1.0.0-1.el7".to_string())
684        );
685    }
686
687    #[test]
688    fn test_build_version_release_no_release() {
689        assert_eq!(
690            build_version_release("1.0.0", ""),
691            Some("1.0.0".to_string())
692        );
693    }
694
695    #[test]
696    fn test_build_version_release_empty() {
697        assert_eq!(build_version_release("", ""), None);
698    }
699
700    #[cfg(feature = "rpm-sqlite")]
701    #[test]
702    fn test_parse_rpm_database_sqlite() {
703        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
704
705        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
706
707        assert_eq!(pkg.package_type, Some(PackageType::Rpm));
708        assert_eq!(
709            pkg.datasource_id,
710            Some(DatasourceId::RpmInstalledDatabaseSqlite)
711        );
712        assert!(pkg.name.is_some());
713    }
714
715    #[cfg(feature = "rpm-sqlite")]
716    #[test]
717    fn test_parse_rpm_database_sqlite_preserves_release_in_version() {
718        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
719
720        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
721
722        assert!(
723            pkg.version
724                .as_ref()
725                .is_some_and(|version| version.contains('-'))
726        );
727    }
728
729    #[test]
730    fn test_build_file_references_skips_invalid_entries() {
731        let file_refs = build_file_references(
732            &[
733                Some("valid".to_string()),
734                Some("".to_string()),
735                Some("ignored".to_string()),
736            ],
737            &[0, 0, u32::MAX],
738            &["/usr/bin/".to_string()],
739        );
740
741        assert_eq!(file_refs.len(), 2);
742        assert_eq!(file_refs[0].path, "/usr/bin/valid");
743        assert_eq!(file_refs[1].path, "/usr/bin/");
744    }
745
746    #[test]
747    fn test_build_package_data_falls_back_to_file_names() {
748        let package = build_package_data(
749            RpmQueryPackage {
750                name: Some("libgcc".to_string()),
751                epoch: None,
752                version: Some("13.1.1".to_string()),
753                release: Some("2.fc38".to_string()),
754                vendor: Some("Fedora Project".to_string()),
755                distribution: None,
756                arch: Some("x86_64".to_string()),
757                platform: None,
758                size: Some(235748),
759                license: Some("GPLv3+".to_string()),
760                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
761                requires: Vec::new(),
762                file_names: vec![
763                    Some("/usr/share/licenses/libgcc/COPYING".to_string()),
764                    Some("/usr/share/licenses/libgcc/COPYING.RUNTIME".to_string()),
765                ],
766                dir_indexes: Vec::new(),
767                base_names: Vec::new(),
768                dir_names: Vec::new(),
769            },
770            DatasourceId::RpmInstalledDatabaseSqlite,
771        );
772
773        assert_eq!(package.file_references.len(), 2);
774        assert_eq!(
775            package.file_references[0].path,
776            "/usr/share/licenses/libgcc/COPYING"
777        );
778        assert_eq!(
779            package.file_references[1].path,
780            "/usr/share/licenses/libgcc/COPYING.RUNTIME"
781        );
782    }
783
784    #[test]
785    fn test_build_package_data_uses_distribution_for_namespace() {
786        let package = build_package_data(
787            RpmQueryPackage {
788                name: Some("libgcc".to_string()),
789                epoch: None,
790                version: Some("13.1.1".to_string()),
791                release: Some("2.fc38".to_string()),
792                vendor: None,
793                distribution: Some("Fedora Project".to_string()),
794                arch: Some("x86_64".to_string()),
795                platform: None,
796                size: Some(235748),
797                license: Some("GPLv3+".to_string()),
798                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
799                requires: Vec::new(),
800                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
801                dir_indexes: Vec::new(),
802                base_names: Vec::new(),
803                dir_names: Vec::new(),
804            },
805            DatasourceId::RpmInstalledDatabaseSqlite,
806        );
807
808        assert_eq!(package.namespace.as_deref(), Some("fedora"));
809    }
810
811    #[test]
812    fn test_build_package_data_normalizes_declared_license_expression() {
813        let package = build_package_data(
814            RpmQueryPackage {
815                name: Some("libgcc".to_string()),
816                epoch: None,
817                version: Some("13.1.1".to_string()),
818                release: Some("2.fc38".to_string()),
819                vendor: Some("Fedora Project".to_string()),
820                distribution: None,
821                arch: Some("x86_64".to_string()),
822                platform: None,
823                size: Some(235748),
824                license: Some("LGPLv2".to_string()),
825                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
826                requires: Vec::new(),
827                file_names: Vec::new(),
828                dir_indexes: Vec::new(),
829                base_names: Vec::new(),
830                dir_names: Vec::new(),
831            },
832            DatasourceId::RpmInstalledDatabaseSqlite,
833        );
834
835        assert_eq!(
836            package.declared_license_expression.as_deref(),
837            Some("lgpl-2.0")
838        );
839        assert_eq!(
840            package.declared_license_expression_spdx.as_deref(),
841            Some("LGPL-2.0-only")
842        );
843        assert_eq!(package.license_detections.len(), 1);
844    }
845
846    #[test]
847    fn test_build_package_data_uses_source_rpm_for_namespace() {
848        let package = build_package_data(
849            RpmQueryPackage {
850                name: Some("libgcc".to_string()),
851                epoch: None,
852                version: Some("13.1.1".to_string()),
853                release: None,
854                vendor: None,
855                distribution: None,
856                arch: Some("x86_64".to_string()),
857                platform: None,
858                size: Some(235748),
859                license: Some("GPLv3+".to_string()),
860                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
861                requires: Vec::new(),
862                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
863                dir_indexes: Vec::new(),
864                base_names: Vec::new(),
865                dir_names: Vec::new(),
866            },
867            DatasourceId::RpmInstalledDatabaseSqlite,
868        );
869
870        assert_eq!(package.namespace.as_deref(), Some("fedora"));
871    }
872
873    #[test]
874    fn test_build_package_data_uses_platform_for_architecture() {
875        let package = build_package_data(
876            RpmQueryPackage {
877                name: Some("libgcc".to_string()),
878                epoch: None,
879                version: Some("13.1.1".to_string()),
880                release: None,
881                vendor: None,
882                distribution: None,
883                arch: None,
884                platform: Some("x86_64-redhat-linux".to_string()),
885                size: Some(235748),
886                license: Some("GPLv3+".to_string()),
887                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
888                requires: Vec::new(),
889                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
890                dir_indexes: Vec::new(),
891                base_names: Vec::new(),
892                dir_names: Vec::new(),
893            },
894            DatasourceId::RpmInstalledDatabaseSqlite,
895        );
896
897        assert_eq!(
898            package.qualifiers.as_ref().and_then(|q| q.get("arch")),
899            Some(&"x86_64".to_string())
900        );
901    }
902}