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