Skip to main content

provenant/parsers/
rpm_parser.rs

1//! Parser for RPM package archives.
2//!
3//! Extracts package metadata and dependencies from binary RPM package (.rpm) files
4//! by reading the embedded header metadata.
5//!
6//! # Supported Formats
7//! - *.rpm (binary RPM package archives)
8//!
9//! # Key Features
10//! - Metadata extraction from RPM headers (name, version, release, architecture)
11//! - Dependency extraction (requires, provides, obsoletes)
12//! - License and distribution information parsing
13//! - Package URL (purl) generation for installed packages
14//! - Graceful handling of malformed or corrupted RPM files
15//!
16//! # Implementation Notes
17//! - Uses `rpm` crate for low-level RPM format parsing
18//! - RPM architecture is captured as namespace in metadata
19//! - Direct dependency tracking (all requires are direct)
20//! - Error handling with `warn!()` logs on parse failures
21
22use std::fs::File;
23use std::io::{BufReader, Read};
24use std::path::Path;
25
26use log::warn;
27use rpm::{IndexTag, Package, PackageMetadata, RPM_MAGIC};
28
29use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
30
31use super::PackageParser;
32
33const PACKAGE_TYPE: PackageType = PackageType::Rpm;
34
35fn default_package_data() -> PackageData {
36    PackageData {
37        package_type: Some(PACKAGE_TYPE),
38        datasource_id: Some(DatasourceId::RpmArchive),
39        ..Default::default()
40    }
41}
42
43pub(crate) fn infer_rpm_namespace(
44    distribution: Option<&str>,
45    vendor: Option<&str>,
46    release: Option<&str>,
47    dist_url: Option<&str>,
48) -> Option<String> {
49    for candidate in [distribution, vendor, dist_url].into_iter().flatten() {
50        let lower = candidate.to_ascii_lowercase();
51        if lower.contains("fedora") || lower.contains("koji") {
52            return Some("fedora".to_string());
53        }
54        if lower.contains("centos") {
55            return Some("centos".to_string());
56        }
57        if lower.contains("red hat") || lower.contains("redhat") || lower.contains("ubi") {
58            return Some("rhel".to_string());
59        }
60        if lower.contains("opensuse") {
61            return Some("opensuse".to_string());
62        }
63        if lower.contains("suse") {
64            return Some("suse".to_string());
65        }
66        if lower.contains("openmandriva") || lower.contains("mandriva") {
67            return Some("openmandriva".to_string());
68        }
69        if lower.contains("mariner") {
70            return Some("mariner".to_string());
71        }
72    }
73
74    if let Some(release) = release {
75        let lower = release.to_ascii_lowercase();
76        if lower.contains(".fc") {
77            return Some("fedora".to_string());
78        }
79        if lower.contains(".el") {
80            return Some("rhel".to_string());
81        }
82        if lower.contains("mdv") || lower.contains("mnb") {
83            return Some("openmandriva".to_string());
84        }
85        if lower.contains("suse") {
86            return Some("suse".to_string());
87        }
88    }
89
90    None
91}
92
93fn rpm_header_string(metadata: &PackageMetadata, tag: IndexTag) -> Option<String> {
94    metadata
95        .header
96        .get_entry_data_as_string(tag)
97        .ok()
98        .and_then(|value| {
99            let trimmed = value.trim();
100            if trimmed.is_empty() || trimmed == "(none)" {
101                None
102            } else {
103                Some(trimmed.to_string())
104            }
105        })
106}
107
108fn rpm_header_string_array(metadata: &PackageMetadata, tag: IndexTag) -> Option<Vec<String>> {
109    metadata
110        .header
111        .get_entry_data_as_string_array(tag)
112        .ok()
113        .map(|items| {
114            items
115                .iter()
116                .map(|item| item.trim().to_string())
117                .filter(|item| !item.is_empty() && item != "(none)")
118                .collect::<Vec<_>>()
119        })
120        .filter(|items| !items.is_empty())
121}
122
123fn infer_vcs_url(metadata: &PackageMetadata, source_urls: &[String]) -> Option<String> {
124    if let Ok(vcs) = metadata.get_vcs()
125        && !vcs.trim().is_empty()
126    {
127        return Some(vcs.to_string());
128    }
129
130    source_urls
131        .iter()
132        .find(|url| url.starts_with("git+") || url.contains("src.fedoraproject.org"))
133        .cloned()
134}
135
136fn build_rpm_qualifiers(
137    architecture: Option<&str>,
138    is_source: bool,
139) -> Option<std::collections::HashMap<String, String>> {
140    let mut qualifiers = std::collections::HashMap::new();
141
142    if let Some(arch) = architecture.filter(|arch| !arch.is_empty()) {
143        qualifiers.insert("arch".to_string(), arch.to_string());
144    }
145
146    if is_source {
147        qualifiers.insert("source".to_string(), "true".to_string());
148    }
149
150    (!qualifiers.is_empty()).then_some(qualifiers)
151}
152
153/// Parser for RPM package archives
154pub struct RpmParser;
155
156impl PackageParser for RpmParser {
157    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
158
159    fn is_match(path: &Path) -> bool {
160        if let Some(ext) = path.extension().and_then(|e| e.to_str())
161            && matches!(ext, "rpm" | "srpm")
162        {
163            return true;
164        }
165
166        let mut file = match File::open(path) {
167            Ok(file) => file,
168            Err(_) => return false,
169        };
170        let mut magic = [0_u8; 4];
171        file.read_exact(&mut magic).is_ok() && magic == RPM_MAGIC
172    }
173
174    fn extract_packages(path: &Path) -> Vec<PackageData> {
175        let file = match File::open(path) {
176            Ok(f) => f,
177            Err(e) => {
178                warn!("Failed to open RPM file {:?}: {}", path, e);
179                return vec![default_package_data()];
180            }
181        };
182
183        let mut reader = BufReader::new(file);
184        let pkg = match Package::parse(&mut reader) {
185            Ok(p) => p,
186            Err(e) => {
187                warn!("Failed to parse RPM file {:?}: {}", path, e);
188                return vec![default_package_data()];
189            }
190        };
191
192        vec![parse_rpm_package(&pkg, path)]
193    }
194}
195
196fn infer_rpm_namespace_from_filename(path: &Path) -> Option<String> {
197    let filename = path.file_name()?.to_str()?.to_ascii_lowercase();
198
199    if filename.contains(".fc") {
200        return Some("fedora".to_string());
201    }
202    if filename.contains(".el") {
203        return Some("rhel".to_string());
204    }
205    if filename.contains("mdv") || filename.contains("mnb") {
206        return Some("openmandriva".to_string());
207    }
208    if filename.contains("opensuse") {
209        return Some("opensuse".to_string());
210    }
211    if filename.contains("suse") {
212        return Some("suse".to_string());
213    }
214
215    None
216}
217
218fn parse_rpm_package(pkg: &Package, path: &Path) -> PackageData {
219    let metadata = &pkg.metadata;
220
221    let name = metadata.get_name().ok().map(|s| s.to_string());
222    let version = build_evr_version(metadata);
223    let description = metadata.get_description().ok().map(|s| s.to_string());
224    let homepage_url = metadata.get_url().ok().map(|s| s.to_string());
225    let architecture = metadata.get_arch().ok().map(|s| s.to_string());
226    let path_str = path.to_string_lossy();
227    let is_source = metadata.is_source_package()
228        || path_str.ends_with(".src.rpm")
229        || path_str.ends_with(".srpm");
230    let distribution = rpm_header_string(metadata, IndexTag::RPMTAG_DISTRIBUTION);
231    let dist_url = rpm_header_string(metadata, IndexTag::RPMTAG_DISTURL);
232    let bug_tracking_url = rpm_header_string(metadata, IndexTag::RPMTAG_BUGURL);
233    let source_urls =
234        rpm_header_string_array(metadata, IndexTag::RPMTAG_SOURCE).unwrap_or_default();
235    let source_rpm = metadata
236        .get_source_rpm()
237        .ok()
238        .filter(|value| !value.is_empty())
239        .map(|value| value.to_string());
240    let namespace = infer_rpm_namespace(
241        distribution.as_deref(),
242        metadata.get_vendor().ok(),
243        metadata.get_release().ok(),
244        dist_url.as_deref(),
245    )
246    .or_else(|| infer_rpm_namespace_from_filename(path));
247
248    let mut parties = Vec::new();
249
250    if let Ok(vendor) = metadata.get_vendor()
251        && !vendor.is_empty()
252    {
253        parties.push(Party {
254            r#type: Some("organization".to_string()),
255            role: Some("vendor".to_string()),
256            name: Some(vendor.to_string()),
257            email: None,
258            url: None,
259            organization: None,
260            organization_url: None,
261            timezone: None,
262        });
263    }
264
265    if let Some(distribution_name) = distribution.as_ref() {
266        parties.push(Party {
267            r#type: Some("organization".to_string()),
268            role: Some("distributor".to_string()),
269            name: Some(distribution_name.clone()),
270            email: None,
271            url: None,
272            organization: None,
273            organization_url: None,
274            timezone: None,
275        });
276    }
277
278    if let Ok(packager) = metadata.get_packager()
279        && !packager.is_empty()
280    {
281        let (name_opt, email_opt) = parse_packager(packager);
282        parties.push(Party {
283            r#type: Some("person".to_string()),
284            role: Some("packager".to_string()),
285            name: name_opt,
286            email: email_opt,
287            url: None,
288            organization: None,
289            organization_url: None,
290            timezone: None,
291        });
292    }
293
294    let extracted_license_statement = metadata.get_license().ok().map(|s| s.to_string());
295
296    let dependencies = extract_rpm_dependencies(pkg, namespace.as_deref());
297
298    let qualifiers = build_rpm_qualifiers(architecture.as_deref(), is_source);
299
300    let mut keywords = Vec::new();
301    if let Ok(group) = metadata.get_group()
302        && !group.is_empty()
303    {
304        keywords.push(group.to_string());
305    }
306
307    let mut extra_data = std::collections::HashMap::new();
308    if let Some(distribution) = distribution.clone() {
309        extra_data.insert(
310            "distribution".to_string(),
311            serde_json::Value::String(distribution),
312        );
313    }
314    if let Some(dist_url) = dist_url.clone() {
315        extra_data.insert("dist_url".to_string(), serde_json::Value::String(dist_url));
316    }
317    if let Ok(build_host) = metadata.get_build_host()
318        && !build_host.is_empty()
319    {
320        extra_data.insert(
321            "build_host".to_string(),
322            serde_json::Value::String(build_host.to_string()),
323        );
324    }
325    if let Ok(build_time) = metadata.get_build_time() {
326        extra_data.insert(
327            "build_time".to_string(),
328            serde_json::Value::Number(serde_json::Number::from(build_time)),
329        );
330    }
331    if !source_urls.is_empty() {
332        extra_data.insert(
333            "source_urls".to_string(),
334            serde_json::Value::Array(
335                source_urls
336                    .iter()
337                    .cloned()
338                    .map(serde_json::Value::String)
339                    .collect(),
340            ),
341        );
342    }
343    if let Some(provides) = extract_rpm_relationships(pkg, RpmRelationshipKind::Provides)
344        && !provides.is_empty()
345    {
346        extra_data.insert(
347            "provides".to_string(),
348            serde_json::Value::Array(
349                provides
350                    .into_iter()
351                    .map(serde_json::Value::String)
352                    .collect(),
353            ),
354        );
355    }
356    if let Some(obsoletes) = extract_rpm_relationships(pkg, RpmRelationshipKind::Obsoletes)
357        && !obsoletes.is_empty()
358    {
359        extra_data.insert(
360            "obsoletes".to_string(),
361            serde_json::Value::Array(
362                obsoletes
363                    .into_iter()
364                    .map(serde_json::Value::String)
365                    .collect(),
366            ),
367        );
368    }
369    let vcs_url = infer_vcs_url(metadata, &source_urls);
370
371    PackageData {
372        datasource_id: Some(DatasourceId::RpmArchive),
373        package_type: Some(PACKAGE_TYPE),
374        namespace: namespace.clone(),
375        name: name.clone(),
376        version: version.clone(),
377        qualifiers,
378        description,
379        homepage_url,
380        size: metadata.get_installed_size().ok(),
381        parties,
382        keywords,
383        bug_tracking_url,
384        extracted_license_statement,
385        dependencies,
386        source_packages: source_rpm.into_iter().collect(),
387        vcs_url,
388        extra_data: (!extra_data.is_empty()).then_some(extra_data),
389        purl: name.as_ref().and_then(|n| {
390            build_rpm_purl(
391                n,
392                version.as_deref(),
393                namespace.as_deref(),
394                architecture.as_deref(),
395                is_source,
396            )
397        }),
398        ..Default::default()
399    }
400}
401
402fn extract_rpm_dependencies(pkg: &Package, namespace: Option<&str>) -> Vec<Dependency> {
403    let mut dependencies = Vec::new();
404
405    if let Ok(requires) = pkg.metadata.get_requires() {
406        for rpm_dep in requires {
407            let purl = build_rpm_purl(
408                &rpm_dep.name,
409                if rpm_dep.version.is_empty() {
410                    None
411                } else {
412                    Some(&rpm_dep.version)
413                },
414                namespace,
415                None,
416                false,
417            );
418
419            let extracted_requirement = if !rpm_dep.version.is_empty() {
420                Some(format_rpm_requirement(&rpm_dep))
421            } else {
422                None
423            };
424
425            dependencies.push(Dependency {
426                purl,
427                extracted_requirement,
428                scope: Some("install".to_string()),
429                is_runtime: Some(true),
430                is_optional: Some(false),
431                is_direct: Some(true),
432                resolved_package: None,
433                extra_data: None,
434                is_pinned: Some(!rpm_dep.version.is_empty()),
435            });
436        }
437    }
438
439    dependencies
440}
441
442enum RpmRelationshipKind {
443    Provides,
444    Obsoletes,
445}
446
447fn extract_rpm_relationships(pkg: &Package, kind: RpmRelationshipKind) -> Option<Vec<String>> {
448    let relationships = match kind {
449        RpmRelationshipKind::Provides => pkg.metadata.get_provides().ok()?,
450        RpmRelationshipKind::Obsoletes => pkg.metadata.get_obsoletes().ok()?,
451    };
452
453    let values: Vec<String> = relationships
454        .into_iter()
455        .map(|dep| format_rpm_requirement(&dep))
456        .filter(|value| !value.is_empty() && value != "(none)")
457        .collect();
458
459    (!values.is_empty()).then_some(values)
460}
461
462fn format_rpm_requirement(dep: &rpm::Dependency) -> String {
463    use rpm::DependencyFlags;
464
465    if dep.version.is_empty() {
466        return dep.name.clone();
467    }
468
469    let operator = if dep.flags.contains(DependencyFlags::EQUAL)
470        && dep.flags.contains(DependencyFlags::LESS)
471    {
472        "<="
473    } else if dep.flags.contains(DependencyFlags::EQUAL)
474        && dep.flags.contains(DependencyFlags::GREATER)
475    {
476        ">="
477    } else if dep.flags.contains(DependencyFlags::EQUAL) {
478        "="
479    } else if dep.flags.contains(DependencyFlags::LESS) {
480        "<"
481    } else if dep.flags.contains(DependencyFlags::GREATER) {
482        ">"
483    } else {
484        ""
485    };
486
487    if operator.is_empty() {
488        dep.name.clone()
489    } else {
490        format!("{} {} {}", dep.name, operator, dep.version)
491    }
492}
493
494fn build_evr_version(metadata: &PackageMetadata) -> Option<String> {
495    let version = metadata.get_version().ok()?;
496    let release = metadata.get_release().ok();
497
498    let mut evr = String::from(version);
499
500    if let Some(r) = release {
501        evr.push('-');
502        evr.push_str(r);
503    }
504
505    Some(evr)
506}
507
508fn parse_packager(packager: &str) -> (Option<String>, Option<String>) {
509    if let Some(email_start) = packager.find('<') {
510        let name = packager[..email_start].trim();
511        if let Some(email_end) = packager.find('>') {
512            let email = &packager[email_start + 1..email_end];
513            return (Some(name.to_string()), Some(email.to_string()));
514        }
515    }
516    (Some(packager.to_string()), None)
517}
518
519fn build_rpm_purl(
520    name: &str,
521    version: Option<&str>,
522    namespace: Option<&str>,
523    architecture: Option<&str>,
524    is_source: bool,
525) -> Option<String> {
526    use packageurl::PackageUrl;
527
528    let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
529
530    if let Some(ns) = namespace {
531        purl.with_namespace(ns).ok()?;
532    }
533
534    if let Some(ver) = version {
535        purl.with_version(ver).ok()?;
536    }
537
538    if let Some(arch) = architecture {
539        purl.add_qualifier("arch", arch).ok()?;
540    }
541
542    if is_source {
543        purl.add_qualifier("source", "true").ok()?;
544    }
545
546    Some(purl.to_string())
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use std::fs;
553    use std::path::PathBuf;
554    use tempfile::NamedTempFile;
555
556    #[test]
557    fn test_rpm_parser_is_match() {
558        assert!(RpmParser::is_match(&PathBuf::from("package.rpm")));
559        assert!(RpmParser::is_match(&PathBuf::from("package.srpm")));
560        assert!(RpmParser::is_match(&PathBuf::from(
561            "test-1.0-1.el7.x86_64.rpm"
562        )));
563        assert!(!RpmParser::is_match(&PathBuf::from("package.deb")));
564        assert!(!RpmParser::is_match(&PathBuf::from("package.tar.gz")));
565    }
566
567    #[test]
568    fn test_rpm_parser_matches_hash_named_source_rpm_by_magic() {
569        let source_fixture = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
570        if !source_fixture.exists() {
571            return;
572        }
573
574        let temp_file = NamedTempFile::new().unwrap();
575        fs::copy(&source_fixture, temp_file.path()).unwrap();
576
577        assert!(RpmParser::is_match(temp_file.path()));
578    }
579
580    #[test]
581    fn test_build_evr_version_simple() {
582        let evr = "1.0-1";
583        assert_eq!(evr, "1.0-1");
584    }
585
586    #[test]
587    fn test_build_evr_version_with_epoch() {
588        let evr = "2:1.0-1";
589        assert!(evr.starts_with("2:"));
590    }
591
592    #[test]
593    fn test_parse_packager() {
594        let (name, email) = parse_packager("John Doe <john@example.com>");
595        assert_eq!(name, Some("John Doe".to_string()));
596        assert_eq!(email, Some("john@example.com".to_string()));
597
598        let (name2, email2) = parse_packager("Plain Name");
599        assert_eq!(name2, Some("Plain Name".to_string()));
600        assert_eq!(email2, None);
601    }
602
603    #[test]
604    fn test_build_rpm_purl() {
605        let purl = build_rpm_purl(
606            "bash",
607            Some("4.4.19-1.el7"),
608            Some("fedora"),
609            Some("x86_64"),
610            false,
611        );
612        assert!(purl.is_some());
613        let purl_str = purl.unwrap();
614        assert!(purl_str.contains("pkg:rpm/fedora/bash"));
615        assert!(purl_str.contains("4.4.19-1.el7"));
616        assert!(purl_str.contains("arch=x86_64"));
617    }
618
619    #[test]
620    fn test_parse_real_rpm() {
621        let test_file = PathBuf::from("testdata/rpm/Eterm-0.9.3-5mdv2007.0.rpm");
622        if !test_file.exists() {
623            eprintln!("Warning: Test file not found, skipping test");
624            return;
625        }
626
627        let pkg = RpmParser::extract_first_package(&test_file);
628
629        assert_eq!(pkg.package_type, Some(PackageType::Rpm));
630
631        if pkg.name.is_some() {
632            assert_eq!(pkg.name, Some("Eterm".to_string()));
633            assert!(pkg.version.is_some());
634        }
635    }
636
637    #[test]
638    fn test_build_rpm_purl_no_namespace() {
639        let purl = build_rpm_purl("package", Some("1.0-1"), None, Some("x86_64"), false);
640        assert!(purl.is_some());
641        let purl_str = purl.unwrap();
642        assert!(purl_str.starts_with("pkg:rpm/package@"));
643        assert!(purl_str.contains("arch=x86_64"));
644    }
645
646    #[test]
647    fn test_rpm_dependency_extraction() {
648        use rpm::{Dependency as RpmDependency, DependencyFlags};
649
650        let rpm_dep = RpmDependency {
651            name: "libc.so.6".to_string(),
652            flags: DependencyFlags::GREATER | DependencyFlags::EQUAL,
653            version: "2.2.5".to_string(),
654        };
655
656        let formatted = format_rpm_requirement(&rpm_dep);
657        assert_eq!(formatted, "libc.so.6 >= 2.2.5");
658
659        let rpm_dep_no_version = RpmDependency {
660            name: "bash".to_string(),
661            flags: DependencyFlags::ANY,
662            version: String::new(),
663        };
664
665        let formatted_no_ver = format_rpm_requirement(&rpm_dep_no_version);
666        assert_eq!(formatted_no_ver, "bash");
667    }
668
669    #[test]
670    fn test_parse_packager_with_parentheses() {
671        let (name, email) = parse_packager("John Doe (Company) <john@example.com>");
672        assert_eq!(name, Some("John Doe (Company)".to_string()));
673        assert_eq!(email, Some("john@example.com".to_string()));
674    }
675
676    #[test]
677    fn test_parse_packager_email_only() {
678        let (name, email) = parse_packager("<noreply@example.com>");
679        assert!(name.is_none() || name == Some(String::new()));
680        assert_eq!(email, Some("noreply@example.com".to_string()));
681    }
682
683    #[test]
684    fn test_rpm_fping_package() {
685        let test_file = PathBuf::from("testdata/rpm/fping-2.4b2-10.fc12.x86_64.rpm");
686        if !test_file.exists() {
687            return;
688        }
689
690        let pkg = RpmParser::extract_first_package(&test_file);
691        if pkg.name.is_some() {
692            assert_eq!(pkg.name, Some("fping".to_string()));
693            assert!(pkg.version.is_some());
694        }
695    }
696
697    #[test]
698    fn test_rpm_archive_extracts_additional_metadata_fields() {
699        let test_file = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
700        if !test_file.exists() {
701            return;
702        }
703
704        let pkg = RpmParser::extract_first_package(&test_file);
705
706        assert_eq!(pkg.name.as_deref(), Some("setup"));
707        assert_eq!(
708            pkg.qualifiers
709                .as_ref()
710                .and_then(|q| q.get("arch"))
711                .map(String::as_str),
712            Some("noarch")
713        );
714        assert!(!pkg.keywords.is_empty());
715        assert!(pkg.size.is_some());
716        assert!(
717            pkg.parties
718                .iter()
719                .any(|party| party.role.as_deref() == Some("packager"))
720        );
721        assert!(
722            pkg.qualifiers
723                .as_ref()
724                .is_some_and(|q| q.get("source") == Some(&"true".to_string()))
725        );
726    }
727
728    #[test]
729    fn test_source_rpm_sets_source_qualifier() {
730        let test_file = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
731        if !test_file.exists() {
732            return;
733        }
734
735        let pkg = RpmParser::extract_first_package(&test_file);
736
737        assert!(
738            pkg.qualifiers
739                .as_ref()
740                .is_some_and(|q| q.get("source") == Some(&"true".to_string()))
741        );
742        assert!(
743            pkg.purl
744                .as_ref()
745                .is_some_and(|purl| purl.contains("source=true"))
746        );
747    }
748
749    #[test]
750    fn test_rpm_archive_extracts_vcs_and_source_metadata() {
751        let package = rpm::PackageBuilder::new(
752            "thunar-sendto-clamtk",
753            "0.08",
754            "GPL-2.0-or-later",
755            "noarch",
756            "Simple virus scanning extension for Thunar",
757        )
758        .release("2.fc40")
759        .vendor("Fedora Project")
760        .packager("Fedora Release Engineering <releng@fedoraproject.org>")
761        .group("Applications/System")
762        .vcs("git+https://src.fedoraproject.org/rpms/thunar-sendto-clamtk.git#5a3f8e92b45f46b464e6924c79d4bf3e11bb1f0e")
763        .build()
764        .unwrap();
765
766        let temp_file = NamedTempFile::new().unwrap();
767        package.write_file(temp_file.path()).unwrap();
768
769        let pkg = RpmParser::extract_first_package(temp_file.path());
770
771        assert_eq!(pkg.namespace.as_deref(), Some("fedora"));
772        assert_eq!(
773            pkg.vcs_url.as_deref(),
774            Some(
775                "git+https://src.fedoraproject.org/rpms/thunar-sendto-clamtk.git#5a3f8e92b45f46b464e6924c79d4bf3e11bb1f0e",
776            )
777        );
778        assert!(
779            pkg.extra_data
780                .as_ref()
781                .is_some_and(|extra| extra.contains_key("build_time"))
782        );
783        assert!(!pkg.keywords.is_empty());
784    }
785
786    #[test]
787    fn test_rpm_archive_preserves_provides_and_obsoletes_relationships() {
788        use rpm::{Dependency as RpmDependency, DependencyFlags};
789
790        let package = rpm::PackageBuilder::new(
791            "demo-rpm",
792            "1.0.0",
793            "MIT",
794            "noarch",
795            "RPM relationship metadata fixture",
796        )
797        .release("1")
798        .provides(RpmDependency {
799            name: "demo-rpm-virtual".to_string(),
800            flags: DependencyFlags::GREATER | DependencyFlags::EQUAL,
801            version: "1.0.0".to_string(),
802        })
803        .obsoletes(RpmDependency {
804            name: "old-demo-rpm".to_string(),
805            flags: DependencyFlags::LESS,
806            version: "0.9.0".to_string(),
807        })
808        .build()
809        .unwrap();
810
811        let temp_file = NamedTempFile::new().unwrap();
812        package.write_file(temp_file.path()).unwrap();
813
814        let pkg = RpmParser::extract_first_package(temp_file.path());
815        let extra = pkg.extra_data.as_ref().expect("extra_data should exist");
816
817        let provides = extra
818            .get("provides")
819            .and_then(|value| value.as_array())
820            .expect("provides should be present");
821        assert!(
822            provides
823                .iter()
824                .any(|value| value.as_str() == Some("demo-rpm-virtual >= 1.0.0"))
825        );
826
827        let obsoletes = extra
828            .get("obsoletes")
829            .and_then(|value| value.as_array())
830            .expect("obsoletes should be present");
831        assert!(
832            obsoletes
833                .iter()
834                .any(|value| value.as_str() == Some("old-demo-rpm < 0.9.0"))
835        );
836    }
837}
838
839crate::register_parser!(
840    "RPM package archive",
841    &["**/*.rpm", "**/*.srpm"],
842    "rpm",
843    "",
844    Some("https://rpm.org/"),
845);