Skip to main content

provenant/parsers/
rpm_db.rs

1//! Parser for RPM database files.
2//!
3//! Extracts installed package metadata from the RPM database maintained by the
4//! system package manager, typically located in /var/lib/rpm/.
5//!
6//! # Supported Formats
7//! - /var/lib/rpm/Packages (BerkleyDB format or SQLite - raw database file)
8//! - Other RPM database index files
9//!
10//! # Key Features
11//! - Installed package metadata extraction from system RPM database
12//! - Database format detection (BDB vs SQLite)
13//! - Multi-version package support
14//! - Package URL (purl) generation with architecture namespace
15//!
16//! # Implementation Notes
17//! - Database location detection (/var/lib/rpm/Packages or variants)
18//! - Graceful error handling for unreadable or corrupted databases
19//! - Returns package data for each installed package entry
20
21use std::path::Path;
22use std::process::Command;
23
24use crate::parser_warn as warn;
25
26use crate::models::{DatasourceId, PackageData, PackageType};
27use crate::models::{Dependency, FileReference};
28
29use super::PackageParser;
30use super::rpm_db_native::{InstalledRpmDbKind, InstalledRpmPackage, read_installed_rpm_packages};
31use super::rpm_parser::infer_rpm_namespace;
32use super::rpm_parser::infer_rpm_namespace_from_filename;
33
34const PACKAGE_TYPE: PackageType = PackageType::Rpm;
35const RPM_QUERY_FORMAT: &str = concat!(
36    "__PKG__\n",
37    "name:%{NAME}\n",
38    "epoch:%{EPOCH}\n",
39    "version:%{VERSION}\n",
40    "release:%{RELEASE}\n",
41    "vendor:%{VENDOR}\n",
42    "distribution:%{DISTRIBUTION}\n",
43    "arch:%{ARCH}\n",
44    "platform:%{PLATFORM}\n",
45    "license:%{LICENSE}\n",
46    "source_rpm:%{SOURCERPM}\n",
47    "size:%{SIZE}\n",
48    "__REQUIRES__\n",
49    "[%{REQUIRENAME}\n]",
50    "__FILES__\n",
51    "[%{FILENAMES}\n]",
52    "__END__\n"
53);
54const PKG_MARKER: &str = "__PKG__";
55const REQUIRES_MARKER: &str = "__REQUIRES__";
56const FILES_MARKER: &str = "__FILES__";
57const END_MARKER: &str = "__END__";
58const RPM_BDB_PATH_SUFFIXES: &[&str] = &["var/lib/rpm/Packages", "usr/lib/sysimage/rpm/Packages"];
59const RPM_NDB_PATH_SUFFIXES: &[&str] = &[
60    "var/lib/rpm/Packages.db",
61    "usr/lib/sysimage/rpm/Packages.db",
62];
63const RPM_SQLITE_PATH_SUFFIXES: &[&str] = &[
64    "var/lib/rpm/rpmdb.sqlite",
65    "usr/lib/sysimage/rpm/rpmdb.sqlite",
66];
67
68#[derive(Debug)]
69struct RpmQueryPackage {
70    name: Option<String>,
71    epoch: Option<String>,
72    version: Option<String>,
73    release: Option<String>,
74    vendor: Option<String>,
75    distribution: Option<String>,
76    arch: Option<String>,
77    platform: Option<String>,
78    size: Option<u64>,
79    license: Option<String>,
80    source_rpm: Option<String>,
81    requires: Vec<String>,
82    file_names: Vec<Option<String>>,
83    dir_indexes: Vec<u32>,
84    base_names: Vec<Option<String>>,
85    dir_names: Vec<String>,
86}
87
88fn default_package_data(datasource_id: DatasourceId) -> PackageData {
89    PackageData {
90        package_type: Some(PACKAGE_TYPE),
91        datasource_id: Some(datasource_id),
92        ..Default::default()
93    }
94}
95
96pub struct RpmBdbDatabaseParser;
97
98impl PackageParser for RpmBdbDatabaseParser {
99    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
100
101    fn is_match(path: &Path) -> bool {
102        path_matches_any_suffix(path, RPM_BDB_PATH_SUFFIXES)
103    }
104
105    fn extract_packages(path: &Path) -> Vec<PackageData> {
106        match parse_rpm_database(path, DatasourceId::RpmInstalledDatabaseBdb) {
107            Ok(pkgs) if !pkgs.is_empty() => pkgs,
108            Ok(_) => vec![default_package_data(DatasourceId::RpmInstalledDatabaseBdb)],
109            Err(e) => {
110                warn!("Failed to parse RPM BDB database {:?}: {}", path, e);
111                vec![default_package_data(DatasourceId::RpmInstalledDatabaseBdb)]
112            }
113        }
114    }
115}
116
117pub struct RpmNdbDatabaseParser;
118
119impl PackageParser for RpmNdbDatabaseParser {
120    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
121
122    fn is_match(path: &Path) -> bool {
123        path_matches_any_suffix(path, RPM_NDB_PATH_SUFFIXES)
124    }
125
126    fn extract_packages(path: &Path) -> Vec<PackageData> {
127        match parse_rpm_database(path, DatasourceId::RpmInstalledDatabaseNdb) {
128            Ok(pkgs) if !pkgs.is_empty() => pkgs,
129            Ok(_) => vec![default_package_data(DatasourceId::RpmInstalledDatabaseNdb)],
130            Err(e) => {
131                warn!("Failed to parse RPM NDB database {:?}: {}", path, e);
132                vec![default_package_data(DatasourceId::RpmInstalledDatabaseNdb)]
133            }
134        }
135    }
136}
137
138#[cfg(feature = "rpm-sqlite")]
139pub struct RpmSqliteDatabaseParser;
140
141#[cfg(feature = "rpm-sqlite")]
142impl PackageParser for RpmSqliteDatabaseParser {
143    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
144
145    fn is_match(path: &Path) -> bool {
146        path_matches_any_suffix(path, RPM_SQLITE_PATH_SUFFIXES)
147    }
148
149    fn extract_packages(path: &Path) -> Vec<PackageData> {
150        match parse_rpm_database(path, DatasourceId::RpmInstalledDatabaseSqlite) {
151            Ok(pkgs) if !pkgs.is_empty() => pkgs,
152            Ok(_) => vec![default_package_data(
153                DatasourceId::RpmInstalledDatabaseSqlite,
154            )],
155            Err(e) => {
156                warn!("Failed to parse RPM SQLite database {:?}: {}", path, e);
157                vec![default_package_data(
158                    DatasourceId::RpmInstalledDatabaseSqlite,
159                )]
160            }
161        }
162    }
163}
164
165fn parse_rpm_database(
166    path: &Path,
167    datasource_id: DatasourceId,
168) -> Result<Vec<PackageData>, String> {
169    let native_kind = native_kind_for_datasource(datasource_id);
170    match read_installed_rpm_packages(path, native_kind) {
171        Ok(packages) => Ok(packages
172            .into_iter()
173            .map(native_package_to_query_package)
174            .map(|pkg| build_package_data(pkg, datasource_id))
175            .collect()),
176        Err(native_error) if !rpm_command_available() => Err(format!(
177            "native installed RPM reader failed for {:?}: {}",
178            path, native_error
179        )),
180        Err(native_error) => {
181            let rpmdb_dir = path
182                .parent()
183                .ok_or_else(|| format!("RPM database path {:?} has no parent directory", path))?;
184
185            query_rpm_database(rpmdb_dir)
186                .map(|packages| {
187                    packages
188                        .into_iter()
189                        .map(|pkg| build_package_data(pkg, datasource_id))
190                        .collect()
191                })
192                .map_err(|fallback_error| {
193                    format!(
194                        "native installed RPM reader failed for {:?}: {}; rpm CLI fallback failed: {}",
195                        path, native_error, fallback_error
196                    )
197                })
198        }
199    }
200}
201
202fn path_matches_suffix(path: &Path, suffix: &str) -> bool {
203    path.to_string_lossy().replace('\\', "/").ends_with(suffix)
204}
205
206fn path_matches_any_suffix(path: &Path, suffixes: &[&str]) -> bool {
207    suffixes
208        .iter()
209        .any(|suffix| path_matches_suffix(path, suffix))
210}
211
212fn native_kind_for_datasource(datasource_id: DatasourceId) -> InstalledRpmDbKind {
213    match datasource_id {
214        DatasourceId::RpmInstalledDatabaseBdb => InstalledRpmDbKind::Bdb,
215        DatasourceId::RpmInstalledDatabaseNdb => InstalledRpmDbKind::Ndb,
216        DatasourceId::RpmInstalledDatabaseSqlite => InstalledRpmDbKind::Sqlite,
217        other => panic!("unexpected datasource for installed RPM DB: {other:?}"),
218    }
219}
220
221fn native_package_to_query_package(package: InstalledRpmPackage) -> RpmQueryPackage {
222    RpmQueryPackage {
223        name: normalize_optional_string(Some(package.name)),
224        epoch: Some(package.epoch.to_string()),
225        version: normalize_optional_string(Some(package.version)),
226        release: normalize_optional_string(Some(package.release)),
227        vendor: normalize_optional_string(Some(package.vendor)),
228        distribution: normalize_optional_string(Some(package.distribution)),
229        arch: normalize_optional_string(Some(package.arch)),
230        platform: normalize_optional_string(Some(package.platform)),
231        size: (package.size > 0).then_some(u64::from(package.size)),
232        license: normalize_optional_string(Some(package.license)),
233        source_rpm: normalize_optional_string(Some(package.source_rpm)),
234        requires: package.requires,
235        file_names: package.file_names.into_iter().map(Some).collect(),
236        dir_indexes: package.dir_indexes,
237        base_names: package.base_names.into_iter().map(Some).collect(),
238        dir_names: package.dir_names,
239    }
240}
241
242fn build_evr_version(epoch: u32, version: &str, release: &str) -> Option<String> {
243    if version.is_empty() {
244        return None;
245    }
246
247    let mut evr = String::new();
248
249    if epoch > 0 {
250        evr.push_str(&format!("{}:", epoch));
251    }
252
253    evr.push_str(version);
254
255    if !release.is_empty() {
256        evr.push('-');
257        evr.push_str(release);
258    }
259
260    Some(evr)
261}
262
263fn build_file_references(
264    base_names: &[Option<String>],
265    dir_indexes: &[u32],
266    dir_names: &[String],
267) -> Vec<FileReference> {
268    if base_names.is_empty() || dir_names.is_empty() {
269        return Vec::new();
270    }
271
272    base_names
273        .iter()
274        .zip(dir_indexes.iter())
275        .filter_map(|(basename, &dir_idx)| {
276            let dirname = dir_names.get(dir_idx as usize)?;
277            let basename = basename.as_deref().unwrap_or_default();
278            let path = format!("{}{}", dirname, basename);
279            if path.is_empty() || path == "/" {
280                return None;
281            }
282            Some(FileReference {
283                path,
284                size: None,
285                sha1: None,
286                md5: None,
287                sha256: None,
288                sha512: None,
289                extra_data: None,
290            })
291        })
292        .collect()
293}
294
295fn build_file_references_from_paths(paths: &[Option<String>]) -> Vec<FileReference> {
296    paths
297        .iter()
298        .filter_map(|path| {
299            let path = path.as_deref()?.trim();
300            if path.is_empty() || path == "/" {
301                return None;
302            }
303
304            Some(FileReference {
305                path: path.to_string(),
306                size: None,
307                sha1: None,
308                md5: None,
309                sha256: None,
310                sha512: None,
311                extra_data: None,
312            })
313        })
314        .collect()
315}
316
317fn query_rpm_database(rpmdb_dir: &Path) -> Result<Vec<RpmQueryPackage>, String> {
318    let rpmdb_dir = rpmdb_dir.canonicalize().map_err(|e| {
319        format!(
320            "Failed to resolve RPM database directory {:?} to an absolute path: {}",
321            rpmdb_dir, e
322        )
323    })?;
324
325    let output = Command::new("rpm")
326        .args(["--dbpath"])
327        .arg(&rpmdb_dir)
328        .args(["--query", "--all", "--queryformat", RPM_QUERY_FORMAT])
329        .output()
330        .map_err(|e| format!("Failed to execute rpm for {:?}: {}", rpmdb_dir, e))?;
331
332    if !output.status.success() {
333        let stderr = String::from_utf8_lossy(&output.stderr);
334        let stdout = String::from_utf8_lossy(&output.stdout);
335        let details = if !stderr.trim().is_empty() {
336            stderr.trim().to_string()
337        } else {
338            stdout.trim().to_string()
339        };
340        return Err(format!(
341            "rpm query failed for {:?} (status: {}): {}",
342            rpmdb_dir, output.status, details
343        ));
344    }
345
346    let stdout = String::from_utf8(output.stdout)
347        .map_err(|e| format!("rpm output for {:?} was not valid UTF-8: {}", rpmdb_dir, e))?;
348
349    parse_rpm_query_output(&stdout)
350}
351
352fn rpm_command_available() -> bool {
353    Command::new("rpm").arg("--version").output().is_ok()
354}
355
356fn parse_rpm_query_output(stdout: &str) -> Result<Vec<RpmQueryPackage>, String> {
357    let mut packages = Vec::new();
358
359    for block in stdout
360        .split(PKG_MARKER)
361        .filter(|block| !block.trim().is_empty())
362    {
363        let mut package = RpmQueryPackage {
364            name: None,
365            epoch: None,
366            version: None,
367            release: None,
368            vendor: None,
369            distribution: None,
370            arch: None,
371            platform: None,
372            size: None,
373            license: None,
374            source_rpm: None,
375            requires: Vec::new(),
376            file_names: Vec::new(),
377            dir_indexes: Vec::new(),
378            base_names: Vec::new(),
379            dir_names: Vec::new(),
380        };
381
382        enum Section {
383            Scalars,
384            Requires,
385            Files,
386        }
387
388        let mut section = Section::Scalars;
389
390        for line in block.lines() {
391            let line = line.trim();
392            if line.is_empty() {
393                continue;
394            }
395
396            if line == REQUIRES_MARKER {
397                section = Section::Requires;
398                continue;
399            }
400            if line == FILES_MARKER {
401                section = Section::Files;
402                continue;
403            }
404            if line == END_MARKER {
405                break;
406            }
407
408            match section {
409                Section::Scalars => {
410                    let Some((key, value)) = line.split_once(':') else {
411                        return Err(format!(
412                            "Failed to parse rpm queryformat scalar line: {line}"
413                        ));
414                    };
415
416                    match key {
417                        "name" => package.name = Some(value.to_string()),
418                        "epoch" => package.epoch = Some(value.to_string()),
419                        "version" => package.version = Some(value.to_string()),
420                        "release" => package.release = Some(value.to_string()),
421                        "vendor" => package.vendor = Some(value.to_string()),
422                        "distribution" => package.distribution = Some(value.to_string()),
423                        "arch" => package.arch = Some(value.to_string()),
424                        "platform" => package.platform = Some(value.to_string()),
425                        "license" => package.license = Some(value.to_string()),
426                        "source_rpm" => package.source_rpm = Some(value.to_string()),
427                        "size" => package.size = value.parse::<u64>().ok(),
428                        _ => {}
429                    }
430                }
431                Section::Requires => package.requires.push(line.to_string()),
432                Section::Files => package.file_names.push(Some(line.to_string())),
433            }
434        }
435
436        packages.push(package);
437    }
438
439    Ok(packages)
440}
441
442fn build_package_data(pkg: RpmQueryPackage, datasource_id: DatasourceId) -> PackageData {
443    let name = normalize_optional_string(pkg.name);
444    let version_raw = normalize_optional_string(pkg.version);
445    let release = normalize_optional_string(pkg.release);
446    let version = build_evr_version(
447        parse_epoch(pkg.epoch),
448        version_raw.as_deref().unwrap_or_default(),
449        release.as_deref().unwrap_or_default(),
450    );
451
452    let vendor = normalize_optional_string(pkg.vendor)
453        .or_else(|| normalize_optional_string(pkg.distribution));
454    let source_rpm = normalize_optional_string(pkg.source_rpm);
455    let namespace =
456        infer_rpm_namespace(None, vendor.as_deref(), release.as_deref(), None).or_else(|| {
457            source_rpm
458                .as_deref()
459                .and_then(|source_rpm| infer_rpm_namespace_from_filename(Path::new(source_rpm)))
460        });
461
462    let architecture = normalize_optional_string(pkg.arch)
463        .or_else(|| infer_platform_architecture(pkg.platform.as_deref()));
464    let dependencies = pkg
465        .requires
466        .into_iter()
467        .filter_map(|require| build_dependency(&require))
468        .collect();
469    let extracted_license_statement = normalize_optional_string(pkg.license);
470    let source_packages = source_rpm.clone().into_iter().collect();
471    let file_references = {
472        let from_dir_components =
473            build_file_references(&pkg.base_names, &pkg.dir_indexes, &pkg.dir_names);
474        if from_dir_components.is_empty() {
475            build_file_references_from_paths(&pkg.file_names)
476        } else {
477            from_dir_components
478        }
479    };
480    let purl = build_package_purl(
481        name.as_deref(),
482        namespace.as_deref(),
483        version.as_deref(),
484        architecture.as_deref(),
485    );
486
487    PackageData {
488        datasource_id: Some(datasource_id),
489        package_type: Some(PACKAGE_TYPE),
490        namespace,
491        name,
492        version,
493        qualifiers: architecture.as_ref().map(|arch| {
494            let mut q = std::collections::HashMap::new();
495            q.insert("arch".to_string(), arch.clone());
496            q
497        }),
498        subpath: None,
499        primary_language: None,
500        description: None,
501        release_date: None,
502        parties: Vec::new(),
503        keywords: Vec::new(),
504        homepage_url: None,
505        download_url: None,
506        size: pkg.size.filter(|size| *size > 0),
507        sha1: None,
508        md5: None,
509        sha256: None,
510        sha512: None,
511        bug_tracking_url: None,
512        code_view_url: None,
513        vcs_url: None,
514        copyright: None,
515        holder: None,
516        declared_license_expression: None,
517        declared_license_expression_spdx: None,
518        license_detections: Vec::new(),
519        other_license_expression: None,
520        other_license_expression_spdx: None,
521        other_license_detections: Vec::new(),
522        extracted_license_statement,
523        notice_text: None,
524        source_packages,
525        file_references,
526        is_private: false,
527        is_virtual: false,
528        extra_data: None,
529        dependencies,
530        repository_homepage_url: None,
531        repository_download_url: None,
532        api_data_url: None,
533        purl,
534    }
535}
536
537fn build_dependency(require: &str) -> Option<Dependency> {
538    let require = require.trim();
539    if require.is_empty() || require.starts_with("rpmlib(") || require.starts_with("config(") {
540        return None;
541    }
542
543    let purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), require)
544        .ok()
545        .map(|p| p.to_string());
546
547    Some(Dependency {
548        purl,
549        extracted_requirement: None,
550        scope: Some("requires".to_string()),
551        is_runtime: Some(true),
552        is_optional: Some(false),
553        is_pinned: Some(false),
554        is_direct: Some(true),
555        resolved_package: None,
556        extra_data: None,
557    })
558}
559
560fn build_package_purl(
561    name: Option<&str>,
562    namespace: Option<&str>,
563    version: Option<&str>,
564    arch: Option<&str>,
565) -> Option<String> {
566    let name = name?;
567    let mut purl = packageurl::PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
568
569    if let Some(namespace) = namespace {
570        purl.with_namespace(namespace).ok()?;
571    }
572
573    if let Some(version) = version {
574        purl.with_version(version).ok()?;
575    }
576
577    if let Some(arch) = arch {
578        purl.add_qualifier("arch", arch).ok()?;
579    }
580
581    Some(purl.to_string())
582}
583
584fn normalize_optional_string(value: Option<String>) -> Option<String> {
585    value.and_then(|value| {
586        let trimmed = value.trim();
587        if trimmed.is_empty() || trimmed == "(none)" || trimmed == "[]" {
588            None
589        } else {
590            Some(trimmed.to_string())
591        }
592    })
593}
594
595fn parse_epoch(value: Option<String>) -> u32 {
596    normalize_optional_string(value)
597        .and_then(|value| value.parse::<u32>().ok())
598        .unwrap_or(0)
599}
600
601fn infer_platform_architecture(platform: Option<&str>) -> Option<String> {
602    let platform = platform?.trim();
603    if platform.is_empty() {
604        return None;
605    }
606
607    platform
608        .split_once('-')
609        .map(|(arch, _)| arch)
610        .filter(|arch| !arch.is_empty())
611        .map(|arch| arch.to_string())
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    use crate::models::DatasourceId;
619    use std::path::PathBuf;
620
621    #[test]
622    fn test_bdb_parser_is_match() {
623        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
624            "/var/lib/rpm/Packages"
625        )));
626        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
627            "rootfs/var/lib/rpm/Packages"
628        )));
629        assert!(RpmBdbDatabaseParser::is_match(&PathBuf::from(
630            "/usr/lib/sysimage/rpm/Packages"
631        )));
632        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
633            "/var/lib/rpm/Packages.db"
634        )));
635        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
636            "lib/modules/datasource/deb/__fixtures__/Packages"
637        )));
638        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from("Packages")));
639        assert!(!RpmBdbDatabaseParser::is_match(&PathBuf::from(
640            "testdata/rpm/var/lib/rpm/Packages.expected.json"
641        )));
642    }
643
644    #[test]
645    fn test_ndb_parser_is_match() {
646        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
647            "usr/lib/sysimage/rpm/Packages.db"
648        )));
649        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
650            "/rootfs/usr/lib/sysimage/rpm/Packages.db"
651        )));
652        assert!(!RpmNdbDatabaseParser::is_match(&PathBuf::from(
653            "usr/lib/rpm/Packages"
654        )));
655        assert!(RpmNdbDatabaseParser::is_match(&PathBuf::from(
656            "var/lib/rpm/Packages.db"
657        )));
658        assert!(!RpmNdbDatabaseParser::is_match(&PathBuf::from(
659            "testdata/rpm/usr/lib/sysimage/rpm/Packages.db.expected.json"
660        )));
661    }
662
663    #[cfg(feature = "rpm-sqlite")]
664    #[test]
665    fn test_sqlite_parser_is_match() {
666        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
667            "var/lib/rpm/rpmdb.sqlite"
668        )));
669        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
670            "/rootfs/var/lib/rpm/rpmdb.sqlite"
671        )));
672        assert!(RpmSqliteDatabaseParser::is_match(&PathBuf::from(
673            "/rootfs/usr/lib/sysimage/rpm/rpmdb.sqlite"
674        )));
675        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
676            "/var/lib/rpm/Packages"
677        )));
678        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
679            "testdata/rpm/rpmdb.sqlite.expected.json"
680        )));
681        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
682            "testdata/rpm/rpmdb.sqlite-shm"
683        )));
684        assert!(!RpmSqliteDatabaseParser::is_match(&PathBuf::from(
685            "testdata/rpm/rpmdb.sqlite-wal"
686        )));
687    }
688
689    #[test]
690    fn test_build_evr_version_full() {
691        assert_eq!(
692            build_evr_version(2, "1.0.0", "1.el7"),
693            Some("2:1.0.0-1.el7".to_string())
694        );
695    }
696
697    #[test]
698    fn test_build_evr_version_no_epoch() {
699        assert_eq!(
700            build_evr_version(0, "1.0.0", "1.el7"),
701            Some("1.0.0-1.el7".to_string())
702        );
703    }
704
705    #[test]
706    fn test_build_evr_version_no_release() {
707        assert_eq!(build_evr_version(0, "1.0.0", ""), Some("1.0.0".to_string()));
708    }
709
710    #[test]
711    fn test_build_evr_version_empty() {
712        assert_eq!(build_evr_version(0, "", ""), None);
713    }
714
715    #[cfg(feature = "rpm-sqlite")]
716    #[test]
717    fn test_parse_rpm_database_sqlite() {
718        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
719
720        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
721
722        assert_eq!(pkg.package_type, Some(PackageType::Rpm));
723        assert_eq!(
724            pkg.datasource_id,
725            Some(DatasourceId::RpmInstalledDatabaseSqlite)
726        );
727        assert!(pkg.name.is_some());
728    }
729
730    #[cfg(feature = "rpm-sqlite")]
731    #[test]
732    fn test_parse_rpm_database_sqlite_preserves_release_in_version() {
733        let test_file = PathBuf::from("testdata/rpm/rpmdb.sqlite");
734
735        let pkg = RpmSqliteDatabaseParser::extract_first_package(&test_file);
736
737        assert!(
738            pkg.version
739                .as_ref()
740                .is_some_and(|version| version.contains('-'))
741        );
742    }
743
744    #[test]
745    fn test_build_file_references_skips_invalid_entries() {
746        let file_refs = build_file_references(
747            &[
748                Some("valid".to_string()),
749                Some("".to_string()),
750                Some("ignored".to_string()),
751            ],
752            &[0, 0, u32::MAX],
753            &["/usr/bin/".to_string()],
754        );
755
756        assert_eq!(file_refs.len(), 2);
757        assert_eq!(file_refs[0].path, "/usr/bin/valid");
758        assert_eq!(file_refs[1].path, "/usr/bin/");
759    }
760
761    #[test]
762    fn test_build_package_data_falls_back_to_file_names() {
763        let package = build_package_data(
764            RpmQueryPackage {
765                name: Some("libgcc".to_string()),
766                epoch: None,
767                version: Some("13.1.1".to_string()),
768                release: Some("2.fc38".to_string()),
769                vendor: Some("Fedora Project".to_string()),
770                distribution: None,
771                arch: Some("x86_64".to_string()),
772                platform: None,
773                size: Some(235748),
774                license: Some("GPLv3+".to_string()),
775                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
776                requires: Vec::new(),
777                file_names: vec![
778                    Some("/usr/share/licenses/libgcc/COPYING".to_string()),
779                    Some("/usr/share/licenses/libgcc/COPYING.RUNTIME".to_string()),
780                ],
781                dir_indexes: Vec::new(),
782                base_names: Vec::new(),
783                dir_names: Vec::new(),
784            },
785            DatasourceId::RpmInstalledDatabaseSqlite,
786        );
787
788        assert_eq!(package.file_references.len(), 2);
789        assert_eq!(
790            package.file_references[0].path,
791            "/usr/share/licenses/libgcc/COPYING"
792        );
793        assert_eq!(
794            package.file_references[1].path,
795            "/usr/share/licenses/libgcc/COPYING.RUNTIME"
796        );
797    }
798
799    #[test]
800    fn test_build_package_data_uses_distribution_for_namespace() {
801        let package = build_package_data(
802            RpmQueryPackage {
803                name: Some("libgcc".to_string()),
804                epoch: None,
805                version: Some("13.1.1".to_string()),
806                release: Some("2.fc38".to_string()),
807                vendor: None,
808                distribution: Some("Fedora Project".to_string()),
809                arch: Some("x86_64".to_string()),
810                platform: None,
811                size: Some(235748),
812                license: Some("GPLv3+".to_string()),
813                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
814                requires: Vec::new(),
815                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
816                dir_indexes: Vec::new(),
817                base_names: Vec::new(),
818                dir_names: Vec::new(),
819            },
820            DatasourceId::RpmInstalledDatabaseSqlite,
821        );
822
823        assert_eq!(package.namespace.as_deref(), Some("fedora"));
824    }
825
826    #[test]
827    fn test_build_package_data_uses_source_rpm_for_namespace() {
828        let package = build_package_data(
829            RpmQueryPackage {
830                name: Some("libgcc".to_string()),
831                epoch: None,
832                version: Some("13.1.1".to_string()),
833                release: None,
834                vendor: None,
835                distribution: None,
836                arch: Some("x86_64".to_string()),
837                platform: None,
838                size: Some(235748),
839                license: Some("GPLv3+".to_string()),
840                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
841                requires: Vec::new(),
842                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
843                dir_indexes: Vec::new(),
844                base_names: Vec::new(),
845                dir_names: Vec::new(),
846            },
847            DatasourceId::RpmInstalledDatabaseSqlite,
848        );
849
850        assert_eq!(package.namespace.as_deref(), Some("fedora"));
851    }
852
853    #[test]
854    fn test_build_package_data_uses_platform_for_architecture() {
855        let package = build_package_data(
856            RpmQueryPackage {
857                name: Some("libgcc".to_string()),
858                epoch: None,
859                version: Some("13.1.1".to_string()),
860                release: None,
861                vendor: None,
862                distribution: None,
863                arch: None,
864                platform: Some("x86_64-redhat-linux".to_string()),
865                size: Some(235748),
866                license: Some("GPLv3+".to_string()),
867                source_rpm: Some("gcc-13.1.1-2.fc38.src.rpm".to_string()),
868                requires: Vec::new(),
869                file_names: vec![Some("/usr/share/licenses/libgcc/COPYING".to_string())],
870                dir_indexes: Vec::new(),
871                base_names: Vec::new(),
872                dir_names: Vec::new(),
873            },
874            DatasourceId::RpmInstalledDatabaseSqlite,
875        );
876
877        assert_eq!(
878            package.qualifiers.as_ref().and_then(|q| q.get("arch")),
879            Some(&"x86_64".to_string())
880        );
881    }
882
883    #[test]
884    fn test_parse_rpm_query_output_parses_queryformat_blocks() {
885        let stdout = r#"
886__PKG__
887name:libgcc
888epoch:(none)
889version:13.1.1
890release:2.fc38
891vendor:Fedora Project
892distribution:Fedora Project
893arch:x86_64
894platform:x86_64-redhat-linux
895license:GPLv3+
896source_rpm:gcc-13.1.1-2.fc38.src.rpm
897size:235748
898__REQUIRES__
899rpmlib(PayloadIsZstd)
900glibc
901__FILES__
902/usr/share/licenses/libgcc/COPYING
903__END__
904__PKG__
905name:coreutils
906epoch:(none)
907version:9.1
908release:12.fc38
909vendor:Fedora Project
910distribution:Fedora Project
911arch:x86_64
912platform:x86_64-redhat-linux-gnu
913license:GPLv3+
914source_rpm:coreutils-9.1-12.fc38.src.rpm
915size:5828674
916__REQUIRES__
917glibc
918__FILES__
919/usr/bin/cat
920__END__
921        "#;
922
923        let packages = parse_rpm_query_output(stdout).expect("rpm queryformat output should parse");
924
925        assert_eq!(packages.len(), 2);
926        assert_eq!(packages[0].name.as_deref(), Some("libgcc"));
927        assert_eq!(packages[0].file_names.len(), 1);
928        assert_eq!(packages[0].requires.len(), 2);
929        assert_eq!(packages[1].name.as_deref(), Some("coreutils"));
930        assert_eq!(packages[1].requires, vec!["glibc".to_string()]);
931    }
932}
933
934#[cfg(feature = "rpm-sqlite")]
935crate::register_parser!(
936    "RPM installed package database (requires `rpm` CLI at runtime)",
937    &[
938        "**/var/lib/rpm/Packages",
939        "**/usr/lib/sysimage/rpm/Packages",
940        "**/var/lib/rpm/Packages.db",
941        "**/usr/lib/sysimage/rpm/Packages.db",
942        "**/var/lib/rpm/rpmdb.sqlite",
943        "**/usr/lib/sysimage/rpm/rpmdb.sqlite"
944    ],
945    "rpm",
946    "",
947    Some("https://rpm.org/"),
948);
949
950#[cfg(not(feature = "rpm-sqlite"))]
951crate::register_parser!(
952    "RPM installed package database (requires `rpm` CLI at runtime)",
953    &[
954        "**/var/lib/rpm/Packages",
955        "**/usr/lib/sysimage/rpm/Packages",
956        "**/var/lib/rpm/Packages.db",
957        "**/usr/lib/sysimage/rpm/Packages.db"
958    ],
959    "rpm",
960    "",
961    Some("https://rpm.org/"),
962);