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
448        .as_deref()
449        .and_then(source_rpm_purl)
450        .into_iter()
451        .collect();
452    let file_references = {
453        let from_dir_components =
454            build_file_references(&pkg.base_names, &pkg.dir_indexes, &pkg.dir_names);
455        if from_dir_components.is_empty() {
456            build_file_references_from_paths(&pkg.file_names)
457        } else {
458            from_dir_components
459        }
460    };
461    let purl = build_package_purl(
462        name.as_deref(),
463        namespace.as_deref(),
464        version.as_deref(),
465        architecture.as_deref(),
466        epoch.as_deref(),
467    );
468
469    PackageData {
470        datasource_id: Some(datasource_id),
471        package_type: Some(PACKAGE_TYPE),
472        namespace,
473        name,
474        version,
475        qualifiers: {
476            let mut q = std::collections::HashMap::new();
477            if let Some(arch) = &architecture {
478                q.insert("arch".to_string(), arch.clone());
479            }
480            if let Some(epoch) = &epoch {
481                q.insert("epoch".to_string(), epoch.clone());
482            }
483            (!q.is_empty()).then_some(q)
484        },
485        subpath: None,
486        primary_language: None,
487        description: None,
488        release_date: None,
489        parties: Vec::new(),
490        keywords: Vec::new(),
491        homepage_url: None,
492        download_url: None,
493        size: pkg.size.filter(|size| *size > 0),
494        sha1: None,
495        md5: None,
496        sha256: None,
497        sha512: None,
498        bug_tracking_url: None,
499        code_view_url: None,
500        vcs_url: None,
501        copyright: None,
502        holder: None,
503        declared_license_expression,
504        declared_license_expression_spdx,
505        license_detections,
506        other_license_expression: None,
507        other_license_expression_spdx: None,
508        other_license_detections: Vec::new(),
509        extracted_license_statement,
510        notice_text: None,
511        source_packages,
512        file_references,
513        is_private: false,
514        is_virtual: false,
515        extra_data: None,
516        dependencies,
517        repository_homepage_url: None,
518        repository_download_url: None,
519        api_data_url: None,
520        purl,
521    }
522}
523
524fn build_dependency(require: &str) -> Option<Dependency> {
525    let require = require.trim();
526    if require.is_empty() || require.starts_with("rpmlib(") || require.starts_with("config(") {
527        return None;
528    }
529
530    let purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), require)
531        .ok()
532        .map(|p| p.to_string());
533
534    Some(Dependency {
535        purl,
536        extracted_requirement: None,
537        scope: Some("requires".to_string()),
538        is_runtime: Some(true),
539        is_optional: Some(false),
540        is_pinned: Some(false),
541        is_direct: Some(true),
542        resolved_package: None,
543        extra_data: None,
544    })
545}
546
547/// Builds the PURL for a binary RPM's source RPM, from its `name-version-release.arch.rpm`
548/// filename.
549///
550/// `source_packages` is specified as a list of *PURLs* — an SRPM is the source
551/// package of a binary RPM — so storing the filename verbatim put a value there
552/// that no consumer can parse. The release is part of the version, matching how
553/// the binary package's own PURL is built, and `arch` stays a qualifier.
554///
555/// Returns `None` when the filename does not decompose, rather than falling back
556/// to the raw string: a non-PURL in this field is exactly the problem.
557pub(super) fn source_rpm_purl(source_rpm: &str) -> Option<String> {
558    let stem = source_rpm.strip_suffix(".rpm")?;
559    let (name_version_release, arch) = stem.rsplit_once('.')?;
560    let (name_version, release) = name_version_release.rsplit_once('-')?;
561    let (name, version) = name_version.rsplit_once('-')?;
562    if name.is_empty() || version.is_empty() || release.is_empty() {
563        return None;
564    }
565
566    build_package_purl(
567        Some(name),
568        None,
569        Some(&format!("{version}-{release}")),
570        Some(arch),
571        None,
572    )
573}
574
575fn build_package_purl(
576    name: Option<&str>,
577    namespace: Option<&str>,
578    version: Option<&str>,
579    arch: Option<&str>,
580    epoch: Option<&str>,
581) -> Option<String> {
582    let name = name?;
583    let mut purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
584
585    if let Some(namespace) = namespace {
586        purl.with_namespace(namespace).ok()?;
587    }
588
589    if let Some(version) = version {
590        purl.with_version(version).ok()?;
591    }
592
593    if let Some(arch) = arch {
594        purl.add_qualifier("arch", arch).ok()?;
595    }
596
597    // The rpm epoch is a qualifier, not part of the version.
598    if let Some(epoch) = epoch {
599        purl.add_qualifier("epoch", epoch).ok()?;
600    }
601
602    Some(purl.to_string())
603}
604
605fn normalize_optional_string(value: Option<String>) -> Option<String> {
606    value.and_then(|value| {
607        let trimmed = value.trim();
608        if trimmed.is_empty() || trimmed == "(none)" || trimmed == "[]" {
609            None
610        } else {
611            Some(trimmed.to_string())
612        }
613    })
614}
615
616fn parse_epoch(value: Option<String>) -> u32 {
617    normalize_optional_string(value)
618        .and_then(|value| value.parse::<u32>().ok())
619        .unwrap_or(0)
620}
621
622fn infer_platform_architecture(platform: Option<&str>) -> Option<String> {
623    let platform = platform?.trim();
624    if platform.is_empty() {
625        return None;
626    }
627
628    platform
629        .split_once('-')
630        .map(|(arch, _)| arch)
631        .filter(|arch| !arch.is_empty())
632        .map(|arch| arch.to_string())
633}
634
635#[cfg(test)]
636mod tests {
637    #[test]
638    fn test_source_rpm_purl_decomposes_a_nevra_filename() {
639        // `source_packages` is specified as PURLs — an SRPM is the source package
640        // of a binary RPM — so the filename was a value no consumer could parse.
641        assert_eq!(
642            super::source_rpm_purl("gcc-13.1.1-2.fc38.src.rpm").as_deref(),
643            Some("pkg:rpm/gcc@13.1.1-2.fc38?arch=src")
644        );
645        // A name containing `-` still splits correctly: only the last two
646        // hyphen-separated fields are version and release.
647        assert_eq!(
648            super::source_rpm_purl("fedora-modular-repos-26-0.4.module_39876f37.src.rpm")
649                .as_deref(),
650            Some("pkg:rpm/fedora-modular-repos@26-0.4.module_39876f37?arch=src")
651        );
652        assert_eq!(
653            super::source_rpm_purl("fping-2.4b2-10.fc12.src.rpm").as_deref(),
654            Some("pkg:rpm/fping@2.4b2-10.fc12?arch=src")
655        );
656
657        // Anything that does not decompose yields no PURL rather than falling
658        // back to the raw string, which is the defect being fixed.
659        for malformed in ["gcc.rpm", "gcc-13.src.rpm", "not-an-rpm", ""] {
660            assert_eq!(super::source_rpm_purl(malformed), None, "{malformed:?}");
661        }
662    }
663
664    use super::*;
665
666    use crate::models::DatasourceId;
667    use std::path::PathBuf;
668
669    #[test]
670    fn test_bdb_parser_is_match() {
671        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
672            "/var/lib/rpm/Packages"
673        )));
674        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
675            "rootfs/var/lib/rpm/Packages"
676        )));
677        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
678            "/usr/lib/sysimage/rpm/Packages"
679        )));
680        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
681            "/var/lib/rpm/Packages.db"
682        )));
683        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
684            "lib/modules/datasource/deb/__fixtures__/Packages"
685        )));
686        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from("Packages")));
687        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
688            "testdata/rpm/var/lib/rpm/Packages.expected.json"
689        )));
690    }
691
692    #[test]
693    fn test_ndb_parser_is_match() {
694        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
695            "usr/lib/sysimage/rpm/Packages.db"
696        )));
697        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
698            "/rootfs/usr/lib/sysimage/rpm/Packages.db"
699        )));
700        assert!(!RpmNdbDatabaseParser::is_match(&PathBuf::from(
701            "usr/lib/rpm/Packages"
702        )));
703        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
704            "var/lib/rpm/Packages.db"
705        )));
706        assert!(!RpmNdbDatabaseParser::is_match(&PathBuf::from(
707            "testdata/rpm/usr/lib/sysimage/rpm/Packages.db.expected.json"
708        )));
709    }
710
711    #[cfg(feature = "rpm-sqlite")]
712    #[test]
713    fn test_sqlite_parser_is_match() {
714        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
715            "var/lib/rpm/rpmdb.sqlite"
716        )));
717        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
718            "/rootfs/var/lib/rpm/rpmdb.sqlite"
719        )));
720        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
721            "/rootfs/usr/lib/sysimage/rpm/rpmdb.sqlite"
722        )));
723        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
724            "/var/lib/rpm/Packages"
725        )));
726        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
727            "testdata/rpm/rpmdb.sqlite.expected.json"
728        )));
729        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
730            "testdata/rpm/rpmdb.sqlite-shm"
731        )));
732        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
733            "testdata/rpm/rpmdb.sqlite-wal"
734        )));
735    }
736
737    #[test]
738    fn test_build_version_release_full() {
739        // Epoch is no longer folded into the version; it is an `?epoch=` qualifier.
740        assert_eq!(
741            build_version_release("1.0.0", "1.el7"),
742            Some("1.0.0-1.el7".to_string())
743        );
744    }
745
746    #[test]
747    fn test_build_version_release_no_release() {
748        assert_eq!(
749            build_version_release("1.0.0", ""),
750            Some("1.0.0".to_string())
751        );
752    }
753
754    #[test]
755    fn test_build_version_release_empty() {
756        assert_eq!(build_version_release("", ""), None);
757    }
758
759    #[cfg(feature = "rpm-sqlite")]
760    #[test]
761    fn test_parse_rpm_database_sqlite() {
762        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
763
764        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
765
766        assert_eq!(pkg.package_type, Some(PackageType::Rpm));
767        assert_eq!(
768            pkg.datasource_id,
769            Some(DatasourceId::RpmInstalledDatabaseSqlite)
770        );
771        assert!(pkg.name.is_some());
772    }
773
774    #[cfg(feature = "rpm-sqlite")]
775    #[test]
776    fn test_parse_rpm_database_sqlite_preserves_release_in_version() {
777        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
778
779        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
780
781        assert!(
782            pkg.version
783                .as_ref()
784                .is_some_and(|version| version.contains('-'))
785        );
786    }
787
788    #[test]
789    fn test_build_file_references_skips_invalid_entries() {
790        let file_refs = build_file_references(
791            &[
792                Some("valid".to_string()),
793                Some("".to_string()),
794                Some("ignored".to_string()),
795            ],
796            &[0, 0, u32::MAX],
797            &["/usr/bin/".to_string()],
798        );
799
800        assert_eq!(file_refs.len(), 2);
801        assert_eq!(file_refs[0].path, "/usr/bin/valid");
802        assert_eq!(file_refs[1].path, "/usr/bin/");
803    }
804
805    #[test]
806    fn test_build_package_data_falls_back_to_file_names() {
807        let package = build_package_data(
808            RpmQueryPackage {
809                name: Some("libgcc".to_string()),
810                epoch: None,
811                version: Some("13.1.1".to_string()),
812                release: Some("2.fc38".to_string()),
813                vendor: Some("Fedora Project".to_string()),
814                distribution: None,
815                arch: Some("x86_64".to_string()),
816                platform: None,
817                size: Some(235748),
818                license: Some("GPLv3+".to_string()),
819                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
820                requires: Vec::new(),
821                file_names: vec![
822                    Some("/usr/share/licenses/libgcc/COPYING".to_string()),
823                    Some("/usr/share/licenses/libgcc/COPYING.RUNTIME".to_string()),
824                ],
825                dir_indexes: Vec::new(),
826                base_names: Vec::new(),
827                dir_names: Vec::new(),
828            },
829            DatasourceId::RpmInstalledDatabaseSqlite,
830        );
831
832        assert_eq!(package.file_references.len(), 2);
833        assert_eq!(
834            package.file_references[0].path,
835            "/usr/share/licenses/libgcc/COPYING"
836        );
837        assert_eq!(
838            package.file_references[1].path,
839            "/usr/share/licenses/libgcc/COPYING.RUNTIME"
840        );
841    }
842
843    #[test]
844    fn test_build_package_data_uses_distribution_for_namespace() {
845        let package = build_package_data(
846            RpmQueryPackage {
847                name: Some("libgcc".to_string()),
848                epoch: None,
849                version: Some("13.1.1".to_string()),
850                release: Some("2.fc38".to_string()),
851                vendor: None,
852                distribution: Some("Fedora Project".to_string()),
853                arch: Some("x86_64".to_string()),
854                platform: None,
855                size: Some(235748),
856                license: Some("GPLv3+".to_string()),
857                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
858                requires: Vec::new(),
859                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
860                dir_indexes: Vec::new(),
861                base_names: Vec::new(),
862                dir_names: Vec::new(),
863            },
864            DatasourceId::RpmInstalledDatabaseSqlite,
865        );
866
867        assert_eq!(package.namespace.as_deref(), Some("fedora"));
868    }
869
870    #[test]
871    fn test_build_package_data_normalizes_declared_license_expression() {
872        let package = build_package_data(
873            RpmQueryPackage {
874                name: Some("libgcc".to_string()),
875                epoch: None,
876                version: Some("13.1.1".to_string()),
877                release: Some("2.fc38".to_string()),
878                vendor: Some("Fedora Project".to_string()),
879                distribution: None,
880                arch: Some("x86_64".to_string()),
881                platform: None,
882                size: Some(235748),
883                license: Some("LGPLv2".to_string()),
884                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
885                requires: Vec::new(),
886                file_names: Vec::new(),
887                dir_indexes: Vec::new(),
888                base_names: Vec::new(),
889                dir_names: Vec::new(),
890            },
891            DatasourceId::RpmInstalledDatabaseSqlite,
892        );
893
894        assert_eq!(
895            package.declared_license_expression.as_deref(),
896            Some("lgpl-2.0")
897        );
898        assert_eq!(
899            package.declared_license_expression_spdx.as_deref(),
900            Some("LGPL-2.0-only")
901        );
902        assert_eq!(package.license_detections.len(), 1);
903    }
904
905    #[test]
906    fn test_build_package_data_uses_source_rpm_for_namespace() {
907        let package = build_package_data(
908            RpmQueryPackage {
909                name: Some("libgcc".to_string()),
910                epoch: None,
911                version: Some("13.1.1".to_string()),
912                release: None,
913                vendor: None,
914                distribution: None,
915                arch: Some("x86_64".to_string()),
916                platform: None,
917                size: Some(235748),
918                license: Some("GPLv3+".to_string()),
919                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
920                requires: Vec::new(),
921                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
922                dir_indexes: Vec::new(),
923                base_names: Vec::new(),
924                dir_names: Vec::new(),
925            },
926            DatasourceId::RpmInstalledDatabaseSqlite,
927        );
928
929        assert_eq!(package.namespace.as_deref(), Some("fedora"));
930    }
931
932    #[test]
933    fn test_build_package_data_uses_platform_for_architecture() {
934        let package = build_package_data(
935            RpmQueryPackage {
936                name: Some("libgcc".to_string()),
937                epoch: None,
938                version: Some("13.1.1".to_string()),
939                release: None,
940                vendor: None,
941                distribution: None,
942                arch: None,
943                platform: Some("x86_64-redhat-linux".to_string()),
944                size: Some(235748),
945                license: Some("GPLv3+".to_string()),
946                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
947                requires: Vec::new(),
948                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
949                dir_indexes: Vec::new(),
950                base_names: Vec::new(),
951                dir_names: Vec::new(),
952            },
953            DatasourceId::RpmInstalledDatabaseSqlite,
954        );
955
956        assert_eq!(
957            package.qualifiers.as_ref().and_then(|q| q.get("arch")),
958            Some(&"x86_64".to_string())
959        );
960    }
961}