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