1use 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
154pub 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);