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