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