1use std::fs::{self, File};
26use std::io::{BufReader, Read};
27use std::path::Path;
28use std::sync::LazyLock;
29
30use crate::parser_warn as warn;
31use regex::Regex;
32use rpm::{
33 HEADER_MAGIC, INDEX_ENTRY_SIZE, INDEX_HEADER_SIZE, IndexTag, LEAD_SIZE, PackageMetadata,
34 RPM_MAGIC,
35};
36
37use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
38use crate::parsers::utils::{MAX_ITERATION_COUNT, MAX_MANIFEST_SIZE, truncate_field};
39
40use super::PackageParser;
41use super::license_normalization::{
42 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
43 empty_declared_license_data, normalize_declared_license_key, normalize_spdx_expression,
44};
45
46const PACKAGE_TYPE: PackageType = PackageType::Rpm;
47const RPM_HEADER_PARSE_LIMIT_BYTES: u64 = MAX_MANIFEST_SIZE.saturating_add(1);
48
49static RE_RPM_LICENSE_AND: LazyLock<Regex> =
50 LazyLock::new(|| Regex::new(r"(?i)\s+and\s+").expect("valid RPM license AND regex"));
51static RE_RPM_LICENSE_OR: LazyLock<Regex> =
52 LazyLock::new(|| Regex::new(r"(?i)\s+or\s+").expect("valid RPM license OR regex"));
53static RE_RPM_LICENSE_COMMA: LazyLock<Regex> =
54 LazyLock::new(|| Regex::new(r"\s*,\s*").expect("valid RPM license comma regex"));
55static RE_RPM_LICENSE_WITH_EXCEPTIONS: LazyLock<Regex> = LazyLock::new(|| {
56 Regex::new(r"(?i)\s+with\s+exceptions\b").expect("valid RPM license exceptions regex")
57});
58
59fn default_package_data() -> PackageData {
60 PackageData {
61 package_type: Some(PACKAGE_TYPE),
62 datasource_id: Some(DatasourceId::RpmArchive),
63 ..Default::default()
64 }
65}
66
67pub(crate) fn infer_rpm_namespace(
68 distribution: Option<&str>,
69 vendor: Option<&str>,
70 release: Option<&str>,
71 dist_url: Option<&str>,
72) -> Option<String> {
73 for candidate in [distribution, vendor, dist_url].into_iter().flatten() {
74 let lower = candidate.to_ascii_lowercase();
75 if lower.contains("fedora") || lower.contains("koji") {
76 return Some("fedora".to_string());
77 }
78 if lower.contains("centos") {
79 return Some("centos".to_string());
80 }
81 if lower.contains("red hat") || lower.contains("redhat") || lower.contains("ubi") {
82 return Some("rhel".to_string());
83 }
84 if lower.contains("opensuse") {
85 return Some("opensuse".to_string());
86 }
87 if lower.contains("suse") {
88 return Some("suse".to_string());
89 }
90 if lower.contains("openmandriva") || lower.contains("mandriva") {
91 return Some("openmandriva".to_string());
92 }
93 if lower.contains("mariner") {
94 return Some("mariner".to_string());
95 }
96 }
97
98 if let Some(release) = release {
99 let lower = release.to_ascii_lowercase();
100 if lower.contains(".fc") {
101 return Some("fedora".to_string());
102 }
103 if lower.contains(".el") {
104 return Some("rhel".to_string());
105 }
106 if lower.contains("mdv") || lower.contains("mnb") {
107 return Some("openmandriva".to_string());
108 }
109 if lower.contains("suse") {
110 return Some("suse".to_string());
111 }
112 }
113
114 None
115}
116
117fn rpm_header_string(metadata: &PackageMetadata, tag: IndexTag) -> Option<String> {
118 metadata
119 .header
120 .get_entry_data_as_string(tag)
121 .ok()
122 .and_then(|value| {
123 let trimmed = value.trim();
124 if trimmed.is_empty() || trimmed == "(none)" {
125 None
126 } else {
127 Some(trimmed.to_string())
128 }
129 })
130}
131
132fn rpm_header_string_array(metadata: &PackageMetadata, tag: IndexTag) -> Option<Vec<String>> {
133 metadata
134 .header
135 .get_entry_data_as_string_array(tag)
136 .ok()
137 .map(|items| {
138 items
139 .iter()
140 .map(|item| item.trim().to_string())
141 .filter(|item| !item.is_empty() && item != "(none)")
142 .collect::<Vec<_>>()
143 })
144 .filter(|items| !items.is_empty())
145}
146
147fn infer_vcs_url(metadata: &PackageMetadata, source_urls: &[String]) -> Option<String> {
148 if let Ok(vcs) = metadata.get_vcs()
149 && !vcs.trim().is_empty()
150 {
151 return Some(vcs.to_string());
152 }
153
154 source_urls
155 .iter()
156 .find(|url| url.starts_with("git+") || url.contains("src.fedoraproject.org"))
157 .cloned()
158}
159
160fn build_rpm_qualifiers(
161 architecture: Option<&str>,
162 epoch: Option<&str>,
163) -> Option<std::collections::HashMap<String, String>> {
164 let mut qualifiers = std::collections::HashMap::new();
165
166 if let Some(arch) = architecture.filter(|arch| !arch.is_empty()) {
167 qualifiers.insert("arch".to_string(), arch.to_string());
168 }
169
170 if let Some(epoch) = epoch.filter(|epoch| !epoch.is_empty()) {
171 qualifiers.insert("epoch".to_string(), epoch.to_string());
172 }
173
174 (!qualifiers.is_empty()).then_some(qualifiers)
175}
176
177pub(crate) fn is_rpm_archive_extension(path: &Path) -> bool {
178 path.extension()
179 .and_then(|e| e.to_str())
180 .is_some_and(|ext| matches!(ext, "rpm" | "srpm"))
181}
182
183pub(crate) fn path_looks_like_rpm_archive(path: &Path) -> bool {
184 if is_rpm_archive_extension(path) {
185 return true;
186 }
187
188 if fs::metadata(path).is_err() {
189 return false;
190 }
191
192 let mut file = match File::open(path) {
193 Ok(file) => file,
194 Err(_) => return false,
195 };
196 let mut magic = [0_u8; 4];
197 file.read_exact(&mut magic).is_ok() && magic == RPM_MAGIC
198}
199
200fn parse_rpm_metadata_only(path: &Path) -> Result<PackageMetadata, String> {
201 let file =
202 File::open(path).map_err(|e| format!("Failed to open RPM file {:?}: {}", path, e))?;
203 let limited_file = file.take(RPM_HEADER_PARSE_LIMIT_BYTES);
204 let mut reader = BufReader::new(limited_file);
205
206 PackageMetadata::parse(&mut reader)
207 .map_err(|e| format!("Failed to parse RPM file {:?}: {}", path, e))
208}
209
210#[derive(Debug, Clone, Copy)]
211struct RpmHeaderEntryView {
212 tag: u32,
213 data_type: u32,
214 offset: usize,
215 num_items: usize,
216}
217
218struct ParsedRpmHeader<'a> {
219 entries: Vec<RpmHeaderEntryView>,
220 store: &'a [u8],
221}
222
223#[derive(Default)]
224struct SalvagedRpmFields {
225 name: Option<String>,
226 epoch: Option<u32>,
227 version: Option<String>,
228 release: Option<String>,
229 summary: Option<String>,
230 description: Option<String>,
231 distribution: Option<String>,
232 vendor: Option<String>,
233 license: Option<String>,
234 packager: Option<String>,
235 group: Option<String>,
236 url: Option<String>,
237 arch: Option<String>,
238 source_rpm: Option<String>,
239 dist_url: Option<String>,
240}
241
242fn read_rpm_header_bytes(path: &Path) -> Result<Vec<u8>, String> {
243 let file =
244 File::open(path).map_err(|e| format!("Failed to open RPM file {:?}: {}", path, e))?;
245 let mut limited_file = file.take(RPM_HEADER_PARSE_LIMIT_BYTES);
246 let mut bytes = Vec::new();
247 limited_file
248 .read_to_end(&mut bytes)
249 .map_err(|e| format!("Failed to read RPM file {:?}: {}", path, e))?;
250 Ok(bytes)
251}
252
253fn parse_index_header(bytes: &[u8], offset: usize) -> Option<(usize, usize)> {
254 let header = bytes.get(offset..offset + INDEX_HEADER_SIZE as usize)?;
255 if header.get(..3)? != HEADER_MAGIC {
256 return None;
257 }
258 if header.get(3).copied()? != 1 {
259 return None;
260 }
261
262 let num_entries = u32::from_be_bytes(header.get(8..12)?.try_into().ok()?) as usize;
263 let data_section_size = u32::from_be_bytes(header.get(12..16)?.try_into().ok()?) as usize;
264 Some((num_entries, data_section_size))
265}
266
267fn parse_header_entries<'a>(
268 bytes: &'a [u8],
269 offset: usize,
270 allow_truncated_store: bool,
271) -> Option<(ParsedRpmHeader<'a>, usize)> {
272 let (num_entries, data_section_size) = parse_index_header(bytes, offset)?;
273 let entries_offset = offset.checked_add(INDEX_HEADER_SIZE as usize)?;
274 let entries_size = num_entries.checked_mul(INDEX_ENTRY_SIZE as usize)?;
275 let store_offset = entries_offset.checked_add(entries_size)?;
276 bytes.get(entries_offset..store_offset)?;
277 let store_end = store_offset.checked_add(data_section_size)?;
278 let store = if allow_truncated_store {
279 bytes.get(store_offset..).unwrap_or(&[])
280 } else {
281 bytes.get(store_offset..store_end)?
282 };
283
284 let mut entries = Vec::with_capacity(num_entries);
285 for index in 0..num_entries {
286 let entry_offset =
287 entries_offset.checked_add(index.checked_mul(INDEX_ENTRY_SIZE as usize)?)?;
288 let entry = bytes.get(entry_offset..entry_offset + INDEX_ENTRY_SIZE as usize)?;
289 entries.push(RpmHeaderEntryView {
290 tag: u32::from_be_bytes(entry.get(0..4)?.try_into().ok()?),
291 data_type: u32::from_be_bytes(entry.get(4..8)?.try_into().ok()?),
292 offset: u32::from_be_bytes(entry.get(8..12)?.try_into().ok()?) as usize,
293 num_items: u32::from_be_bytes(entry.get(12..16)?.try_into().ok()?) as usize,
294 });
295 }
296
297 Some((ParsedRpmHeader { entries, store }, store_end))
298}
299
300fn parse_main_rpm_header(bytes: &[u8]) -> Option<ParsedRpmHeader<'_>> {
301 if bytes.get(..RPM_MAGIC.len())? != RPM_MAGIC {
302 return None;
303 }
304
305 let (_, signature_end) = parse_header_entries(bytes, LEAD_SIZE as usize, false)?;
306 let signature_padding = (8 - (signature_end - (LEAD_SIZE as usize)) % 8) % 8;
307 let main_header_offset = signature_end.checked_add(signature_padding)?;
308 let (header, _) = parse_header_entries(bytes, main_header_offset, true)?;
309 Some(header)
310}
311
312fn read_header_string(store: &[u8], offset: usize) -> Option<(String, usize)> {
313 let remaining = store.get(offset..)?;
314 let nul = remaining.iter().position(|byte| *byte == 0)?;
315 let text = String::from_utf8_lossy(&remaining[..nul])
316 .trim()
317 .to_string();
318 let next_offset = offset.checked_add(nul)?.checked_add(1)?;
319 if text.is_empty() || text == "(none)" {
320 None
321 } else {
322 Some((text, next_offset))
323 }
324}
325
326fn read_entry_first_string(header: &ParsedRpmHeader<'_>, tag: u32) -> Option<String> {
327 let entry = header.entries.iter().find(|entry| entry.tag == tag)?;
328 match entry.data_type {
329 6 => read_header_string(header.store, entry.offset).map(|(value, _)| value),
330 8 | 9 => {
331 let mut offset = entry.offset;
332 let mut first_value = None;
333 for _ in 0..entry.num_items {
334 let (value, next_offset) = read_header_string(header.store, offset)?;
335 first_value.get_or_insert(value);
336 offset = next_offset;
337 }
338 first_value
339 }
340 _ => None,
341 }
342}
343
344fn read_entry_first_u32(header: &ParsedRpmHeader<'_>, tag: u32) -> Option<u32> {
345 let entry = header.entries.iter().find(|entry| entry.tag == tag)?;
346 if entry.data_type != 4 {
348 return None;
349 }
350 let bytes = header
351 .store
352 .get(entry.offset..entry.offset.checked_add(4)?)?;
353 Some(u32::from_be_bytes(bytes.try_into().ok()?))
354}
355
356fn salvage_rpm_header_fields(path: &Path) -> Option<SalvagedRpmFields> {
357 let bytes = read_rpm_header_bytes(path).ok()?;
358 let header = parse_main_rpm_header(&bytes)?;
359
360 Some(SalvagedRpmFields {
361 name: read_entry_first_string(&header, IndexTag::RPMTAG_NAME as u32).map(truncate_field),
362 epoch: read_entry_first_u32(&header, IndexTag::RPMTAG_EPOCH as u32),
363 version: read_entry_first_string(&header, IndexTag::RPMTAG_VERSION as u32)
364 .map(truncate_field),
365 release: read_entry_first_string(&header, IndexTag::RPMTAG_RELEASE as u32)
366 .map(truncate_field),
367 summary: read_entry_first_string(&header, IndexTag::RPMTAG_SUMMARY as u32)
368 .map(truncate_field),
369 description: read_entry_first_string(&header, IndexTag::RPMTAG_DESCRIPTION as u32)
370 .map(truncate_field),
371 distribution: read_entry_first_string(&header, IndexTag::RPMTAG_DISTRIBUTION as u32)
372 .map(truncate_field),
373 vendor: read_entry_first_string(&header, IndexTag::RPMTAG_VENDOR as u32)
374 .map(truncate_field),
375 license: read_entry_first_string(&header, IndexTag::RPMTAG_LICENSE as u32)
376 .map(truncate_field),
377 packager: read_entry_first_string(&header, IndexTag::RPMTAG_PACKAGER as u32)
378 .map(truncate_field),
379 group: read_entry_first_string(&header, IndexTag::RPMTAG_GROUP as u32).map(truncate_field),
380 url: read_entry_first_string(&header, IndexTag::RPMTAG_URL as u32).map(truncate_field),
381 arch: read_entry_first_string(&header, IndexTag::RPMTAG_ARCH as u32).map(truncate_field),
382 source_rpm: read_entry_first_string(&header, IndexTag::RPMTAG_SOURCERPM as u32)
383 .map(truncate_field),
384 dist_url: read_entry_first_string(&header, IndexTag::RPMTAG_DISTURL as u32)
385 .map(truncate_field),
386 })
387}
388
389fn build_salvaged_rpm_package(path: &Path, fields: SalvagedRpmFields) -> Option<PackageData> {
390 let name = fields.name?;
391 let mut version = fields.version;
392 if let Some(release) = fields.release.as_deref() {
393 let mut evr = version.take().unwrap_or_default();
394 if !evr.is_empty() {
395 evr.push('-');
396 }
397 evr.push_str(release);
398 version = Some(truncate_field(evr));
399 }
400
401 let namespace = infer_rpm_namespace(
402 fields.distribution.as_deref(),
403 fields.vendor.as_deref(),
404 fields.release.as_deref(),
405 fields.dist_url.as_deref(),
406 )
407 .or_else(|| infer_rpm_namespace_from_filename(path))
408 .map(truncate_field);
409 let is_source =
410 path.to_string_lossy().ends_with(".src.rpm") || path.to_string_lossy().ends_with(".srpm");
411 let epoch = fields
414 .epoch
415 .filter(|epoch| *epoch > 0)
416 .map(|e| e.to_string());
417 let qualifiers = build_rpm_qualifiers(fields.arch.as_deref(), epoch.as_deref());
418
419 let mut parties = Vec::new();
420 if let Some(vendor) = fields.vendor.clone() {
421 parties.push(Party {
422 r#type: Some(PartyType::Organization),
423 role: Some("vendor".to_string()),
424 name: Some(vendor),
425 email: None,
426 url: None,
427 organization: None,
428 organization_url: None,
429 timezone: None,
430 });
431 }
432 if let Some(distribution) = fields.distribution.clone() {
433 parties.push(Party {
434 r#type: Some(PartyType::Organization),
435 role: Some("distributor".to_string()),
436 name: Some(distribution),
437 email: None,
438 url: None,
439 organization: None,
440 organization_url: None,
441 timezone: None,
442 });
443 }
444 if let Some(packager) = fields.packager.as_deref() {
445 let (name_opt, email_opt) = parse_packager(packager);
446 parties.push(Party {
447 r#type: Some(PartyType::Person),
448 role: Some("packager".to_string()),
449 name: name_opt.map(truncate_field),
450 email: email_opt.map(truncate_field),
451 url: None,
452 organization: None,
453 organization_url: None,
454 timezone: None,
455 });
456 }
457
458 let extracted_license_statement = fields.license.map(truncate_field);
459 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
460 extracted_license_statement
461 .as_deref()
462 .and_then(normalize_rpm_declared_license)
463 .map(|normalized| {
464 build_declared_license_data(
465 normalized,
466 DeclaredLicenseMatchMetadata::single_line(
467 extracted_license_statement.as_deref().unwrap_or_default(),
468 ),
469 )
470 })
471 .map(|(expr, spdx, detections)| {
472 (
473 expr.map(truncate_field),
474 spdx.map(truncate_field),
475 detections,
476 )
477 })
478 .unwrap_or_else(empty_declared_license_data);
479
480 let mut extra_data = std::collections::HashMap::new();
481 if is_source {
484 extra_data.insert("is_source".to_string(), serde_json::Value::Bool(true));
485 }
486 if let Some(distribution) = fields.distribution.clone() {
487 extra_data.insert(
488 "distribution".to_string(),
489 serde_json::Value::String(distribution),
490 );
491 }
492 if let Some(dist_url) = fields.dist_url.clone() {
493 extra_data.insert("dist_url".to_string(), serde_json::Value::String(dist_url));
494 }
495
496 Some(PackageData {
497 datasource_id: Some(DatasourceId::RpmArchive),
498 package_type: Some(PACKAGE_TYPE),
499 namespace: namespace.clone(),
500 name: Some(name.clone()),
501 version: version.clone(),
502 qualifiers,
503 description: fields.description.or(fields.summary),
504 homepage_url: fields.url,
505 parties,
506 keywords: fields.group.into_iter().collect(),
507 declared_license_expression,
508 declared_license_expression_spdx,
509 license_detections,
510 extracted_license_statement,
511 source_packages: fields.source_rpm.into_iter().collect(),
512 extra_data: (!extra_data.is_empty()).then_some(extra_data),
513 purl: build_rpm_purl(
514 &name,
515 version.as_deref(),
516 namespace.as_deref(),
517 fields.arch.as_deref(),
518 epoch.as_deref(),
519 )
520 .map(truncate_field),
521 ..Default::default()
522 })
523}
524
525pub(crate) fn extract_rpm_packages(path: &Path) -> Vec<PackageData> {
526 if let Err(e) = fs::metadata(path) {
527 warn!("Cannot stat RPM file {:?}: {}", path, e);
528 return vec![default_package_data()];
529 }
530
531 let metadata = match parse_rpm_metadata_only(path) {
532 Ok(metadata) => metadata,
533 Err(message) => {
534 if let Some(package) = salvage_rpm_header_fields(path)
535 .and_then(|fields| build_salvaged_rpm_package(path, fields))
536 {
537 return vec![package];
538 }
539 warn!("{}", message);
540 return vec![default_package_data()];
541 }
542 };
543
544 vec![parse_rpm_package(&metadata, path)]
545}
546
547pub struct RpmParser;
549
550impl PackageParser for RpmParser {
551 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
552
553 fn is_match(path: &Path) -> bool {
554 path_looks_like_rpm_archive(path)
555 }
556
557 fn extract_packages(path: &Path) -> Vec<PackageData> {
558 extract_rpm_packages(path)
559 }
560
561 fn metadata() -> Vec<super::metadata::ParserMetadata> {
562 vec![super::metadata::ParserMetadata {
563 description: "RPM package archive",
564 file_patterns: &["**/*.rpm", "**/*.srpm"],
565 package_type: "rpm",
566 primary_language: "",
567 documentation_url: Some("https://rpm.org/"),
568 }]
569 }
570}
571
572pub(crate) fn infer_rpm_namespace_from_filename(path: &Path) -> Option<String> {
573 let filename = path.file_name()?.to_str()?.to_ascii_lowercase();
574
575 if filename.contains(".fc") {
576 return Some("fedora".to_string());
577 }
578 if filename.contains(".el") {
579 return Some("rhel".to_string());
580 }
581 if filename.contains("mdv") || filename.contains("mnb") {
582 return Some("openmandriva".to_string());
583 }
584 if filename.contains("opensuse") {
585 return Some("opensuse".to_string());
586 }
587 if filename.contains("suse") {
588 return Some("suse".to_string());
589 }
590
591 None
592}
593
594fn parse_rpm_package(metadata: &PackageMetadata, path: &Path) -> PackageData {
595 let name = metadata
596 .get_name()
597 .ok()
598 .map(|s| truncate_field(s.to_string()));
599 let version = build_evr_version(metadata).map(truncate_field);
600 let epoch = metadata
603 .get_epoch()
604 .ok()
605 .filter(|epoch| *epoch > 0)
606 .map(|epoch| epoch.to_string());
607 let description = metadata
608 .get_description()
609 .ok()
610 .map(|s| truncate_field(s.to_string()));
611 let homepage_url = metadata
612 .get_url()
613 .ok()
614 .map(|s| truncate_field(s.to_string()));
615 let architecture = metadata
616 .get_arch()
617 .ok()
618 .map(|s| truncate_field(s.to_string()));
619 let path_str = path.to_string_lossy();
620 let is_source = metadata.is_source_package()
621 || path_str.ends_with(".src.rpm")
622 || path_str.ends_with(".srpm");
623 let distribution =
624 rpm_header_string(metadata, IndexTag::RPMTAG_DISTRIBUTION).map(truncate_field);
625 let dist_url = rpm_header_string(metadata, IndexTag::RPMTAG_DISTURL).map(truncate_field);
626 let bug_tracking_url = rpm_header_string(metadata, IndexTag::RPMTAG_BUGURL).map(truncate_field);
627 let source_urls =
628 rpm_header_string_array(metadata, IndexTag::RPMTAG_SOURCE).unwrap_or_default();
629 let source_rpm = metadata
630 .get_source_rpm()
631 .ok()
632 .filter(|value| !value.is_empty())
633 .map(|value| truncate_field(value.to_string()));
634 let namespace = infer_rpm_namespace(
635 distribution.as_deref(),
636 metadata.get_vendor().ok(),
637 metadata.get_release().ok(),
638 dist_url.as_deref(),
639 )
640 .or_else(|| infer_rpm_namespace_from_filename(path))
641 .map(truncate_field);
642
643 let mut parties = Vec::new();
644
645 if let Ok(vendor) = metadata.get_vendor()
646 && !vendor.is_empty()
647 {
648 parties.push(Party {
649 r#type: Some(PartyType::Organization),
650 role: Some("vendor".to_string()),
651 name: Some(truncate_field(vendor.to_string())),
652 email: None,
653 url: None,
654 organization: None,
655 organization_url: None,
656 timezone: None,
657 });
658 }
659
660 if let Some(distribution_name) = distribution.as_ref() {
661 parties.push(Party {
662 r#type: Some(PartyType::Organization),
663 role: Some("distributor".to_string()),
664 name: Some(distribution_name.clone()),
665 email: None,
666 url: None,
667 organization: None,
668 organization_url: None,
669 timezone: None,
670 });
671 }
672
673 if let Ok(packager) = metadata.get_packager()
674 && !packager.is_empty()
675 {
676 let (name_opt, email_opt) = parse_packager(packager);
677 parties.push(Party {
678 r#type: Some(PartyType::Person),
679 role: Some("packager".to_string()),
680 name: name_opt.map(truncate_field),
681 email: email_opt.map(truncate_field),
682 url: None,
683 organization: None,
684 organization_url: None,
685 timezone: None,
686 });
687 }
688
689 let extracted_license_statement = metadata
690 .get_license()
691 .ok()
692 .map(|s| truncate_field(s.to_string()));
693 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
694 extracted_license_statement
695 .as_deref()
696 .and_then(normalize_rpm_declared_license)
697 .map(|normalized| {
698 build_declared_license_data(
699 normalized,
700 DeclaredLicenseMatchMetadata::single_line(
701 extracted_license_statement.as_deref().unwrap_or_default(),
702 ),
703 )
704 })
705 .map(|(expr, spdx, detections)| {
706 (
707 expr.map(truncate_field),
708 spdx.map(truncate_field),
709 detections,
710 )
711 })
712 .unwrap_or_else(empty_declared_license_data);
713
714 let dependencies = extract_rpm_dependencies(metadata, namespace.as_deref());
715
716 let qualifiers = build_rpm_qualifiers(architecture.as_deref(), epoch.as_deref());
717
718 let mut keywords = Vec::new();
719 if let Ok(group) = metadata.get_group()
720 && !group.is_empty()
721 {
722 keywords.push(truncate_field(group.to_string()));
723 }
724
725 let mut extra_data = std::collections::HashMap::new();
726 if is_source {
728 extra_data.insert("is_source".to_string(), serde_json::Value::Bool(true));
729 }
730 if let Some(distribution) = distribution.clone() {
731 extra_data.insert(
732 "distribution".to_string(),
733 serde_json::Value::String(distribution),
734 );
735 }
736 if let Some(dist_url) = dist_url.clone() {
737 extra_data.insert("dist_url".to_string(), serde_json::Value::String(dist_url));
738 }
739 if let Ok(build_host) = metadata.get_build_host()
740 && !build_host.is_empty()
741 {
742 extra_data.insert(
743 "build_host".to_string(),
744 serde_json::Value::String(build_host.to_string()),
745 );
746 }
747 if let Ok(build_time) = metadata.get_build_time() {
748 extra_data.insert(
749 "build_time".to_string(),
750 serde_json::Value::Number(serde_json::Number::from(build_time)),
751 );
752 }
753 if !source_urls.is_empty() {
754 extra_data.insert(
755 "source_urls".to_string(),
756 serde_json::Value::Array(
757 source_urls
758 .iter()
759 .cloned()
760 .map(serde_json::Value::String)
761 .collect(),
762 ),
763 );
764 }
765 if let Some(provides) = extract_rpm_relationships(metadata, RpmRelationshipKind::Provides)
766 && !provides.is_empty()
767 {
768 extra_data.insert(
769 "provides".to_string(),
770 serde_json::Value::Array(
771 provides
772 .into_iter()
773 .map(serde_json::Value::String)
774 .collect(),
775 ),
776 );
777 }
778 if let Some(obsoletes) = extract_rpm_relationships(metadata, RpmRelationshipKind::Obsoletes)
779 && !obsoletes.is_empty()
780 {
781 extra_data.insert(
782 "obsoletes".to_string(),
783 serde_json::Value::Array(
784 obsoletes
785 .into_iter()
786 .map(serde_json::Value::String)
787 .collect(),
788 ),
789 );
790 }
791 let vcs_url = infer_vcs_url(metadata, &source_urls).map(truncate_field);
792
793 PackageData {
794 datasource_id: Some(DatasourceId::RpmArchive),
795 package_type: Some(PACKAGE_TYPE),
796 namespace: namespace.clone(),
797 name: name.clone(),
798 version: version.clone(),
799 qualifiers,
800 description,
801 homepage_url,
802 size: metadata.get_installed_size().ok(),
803 parties,
804 keywords,
805 bug_tracking_url,
806 declared_license_expression,
807 declared_license_expression_spdx,
808 license_detections,
809 extracted_license_statement,
810 dependencies,
811 source_packages: source_rpm.into_iter().collect(),
812 vcs_url,
813 extra_data: (!extra_data.is_empty()).then_some(extra_data),
814 purl: name.as_ref().and_then(|n| {
815 build_rpm_purl(
816 n,
817 version.as_deref(),
818 namespace.as_deref(),
819 architecture.as_deref(),
820 epoch.as_deref(),
821 )
822 .map(truncate_field)
823 }),
824 ..Default::default()
825 }
826}
827
828pub(crate) fn normalize_rpm_declared_license(statement: &str) -> Option<NormalizedDeclaredLicense> {
829 let trimmed = statement.trim();
830 if trimmed.is_empty() {
831 return None;
832 }
833
834 let rewritten = canonicalize_rpm_license_statement(trimmed);
835 if let Some(normalized) = normalize_spdx_expression(&rewritten) {
836 return Some(normalized);
837 }
838
839 let is_simple_key = !trimmed.contains(' ')
840 && !trimmed.contains(',')
841 && !trimmed.contains('(')
842 && !trimmed.contains(')');
843 if is_simple_key {
844 return normalize_declared_license_key(trimmed);
845 }
846
847 None
848}
849
850fn canonicalize_rpm_license_statement(statement: &str) -> String {
851 let mut rewritten = statement.trim().to_string();
852
853 for (from, to) in [
854 ("LGPLv2.1+", "LGPL-2.1-or-later"),
855 ("LGPLv2.1", "LGPL-2.1-only"),
856 ("LGPLv2+", "LGPL-2.0-or-later"),
857 ("LGPLv2", "LGPL-2.0-only"),
858 ("LGPLv3+", "LGPL-3.0-or-later"),
859 ("LGPLv3", "LGPL-3.0-only"),
860 ("GPLv2+", "GPL-2.0-or-later"),
861 ("GPLv2", "GPL-2.0-only"),
862 ("GPLv3+", "GPL-3.0-or-later"),
863 ("GPLv3", "GPL-3.0-only"),
864 ("GPLV2+", "GPL-2.0-or-later"),
865 ("MPLv2.0", "MPL-2.0"),
866 ("MPLv1.1", "MPL-1.1"),
867 ("BSD with advertising", "BSD-4-Clause-UC"),
868 ("Public Domain", "LicenseRef-scancode-public-domain"),
869 ("public domain", "LicenseRef-scancode-public-domain"),
870 ("OpenLDAP", "OLDAP-2.8"),
871 ("OpenSSL", "OpenSSL"),
872 ("Sleepycat", "Sleepycat"),
873 ("zlib", "Zlib"),
874 ("Boost", "BSL-1.0"),
875 ("BSD", "BSD-3-Clause"),
876 ] {
877 rewritten = rewritten.replace(from, to);
878 }
879
880 rewritten = RE_RPM_LICENSE_WITH_EXCEPTIONS
881 .replace_all(&rewritten, "")
882 .into_owned();
883 rewritten = RE_RPM_LICENSE_COMMA
884 .replace_all(&rewritten, " AND ")
885 .into_owned();
886 rewritten = RE_RPM_LICENSE_AND
887 .replace_all(&rewritten, " AND ")
888 .into_owned();
889 rewritten = RE_RPM_LICENSE_OR
890 .replace_all(&rewritten, " OR ")
891 .into_owned();
892
893 rewritten.split_whitespace().collect::<Vec<_>>().join(" ")
894}
895
896fn extract_rpm_dependencies(
897 metadata: &PackageMetadata,
898 namespace: Option<&str>,
899) -> Vec<Dependency> {
900 let mut dependencies = Vec::new();
901
902 if let Ok(requires) = metadata.get_requires() {
903 for rpm_dep in requires {
904 if dependencies.len() >= MAX_ITERATION_COUNT {
905 warn!(
906 "RPM dependency iteration capped at {} items",
907 MAX_ITERATION_COUNT
908 );
909 break;
910 }
911 let purl = build_rpm_purl(
912 &rpm_dep.name,
913 if rpm_dep.version.is_empty() {
914 None
915 } else {
916 Some(&rpm_dep.version)
917 },
918 namespace,
919 None,
920 None,
921 )
922 .map(truncate_field);
923
924 let extracted_requirement = if !rpm_dep.version.is_empty() {
925 Some(truncate_field(format_rpm_requirement(&rpm_dep)))
926 } else {
927 None
928 };
929
930 dependencies.push(Dependency {
931 purl,
932 extracted_requirement,
933 scope: Some("install".to_string()),
934 is_runtime: Some(true),
935 is_optional: Some(false),
936 is_direct: Some(true),
937 resolved_package: None,
938 extra_data: None,
939 is_pinned: Some(!rpm_dep.version.is_empty()),
940 });
941 }
942 }
943
944 dependencies
945}
946
947enum RpmRelationshipKind {
948 Provides,
949 Obsoletes,
950}
951
952fn extract_rpm_relationships(
953 metadata: &PackageMetadata,
954 kind: RpmRelationshipKind,
955) -> Option<Vec<String>> {
956 let relationships = match kind {
957 RpmRelationshipKind::Provides => metadata.get_provides().ok()?,
958 RpmRelationshipKind::Obsoletes => metadata.get_obsoletes().ok()?,
959 };
960
961 let mut count = 0usize;
962 let values: Vec<String> = relationships
963 .into_iter()
964 .take(MAX_ITERATION_COUNT)
965 .map(|dep| format_rpm_requirement(&dep))
966 .filter(|value| !value.is_empty() && value != "(none)")
967 .inspect(|_| count += 1)
968 .collect();
969
970 if count >= MAX_ITERATION_COUNT {
971 warn!(
972 "RPM relationship iteration capped at {} items",
973 MAX_ITERATION_COUNT
974 );
975 }
976
977 (!values.is_empty()).then_some(values)
978}
979
980fn format_rpm_requirement(dep: &rpm::Dependency) -> String {
981 use rpm::DependencyFlags;
982
983 if dep.version.is_empty() {
984 return dep.name.clone();
985 }
986
987 let operator = if dep.flags.contains(DependencyFlags::EQUAL)
988 && dep.flags.contains(DependencyFlags::LESS)
989 {
990 "<="
991 } else if dep.flags.contains(DependencyFlags::EQUAL)
992 && dep.flags.contains(DependencyFlags::GREATER)
993 {
994 ">="
995 } else if dep.flags.contains(DependencyFlags::EQUAL) {
996 "="
997 } else if dep.flags.contains(DependencyFlags::LESS) {
998 "<"
999 } else if dep.flags.contains(DependencyFlags::GREATER) {
1000 ">"
1001 } else {
1002 ""
1003 };
1004
1005 if operator.is_empty() {
1006 dep.name.clone()
1007 } else {
1008 format!("{} {} {}", dep.name, operator, dep.version)
1009 }
1010}
1011
1012fn build_evr_version(metadata: &PackageMetadata) -> Option<String> {
1013 let version = metadata.get_version().ok()?;
1014 let release = metadata.get_release().ok();
1015
1016 let mut evr = String::from(version);
1017
1018 if let Some(r) = release {
1019 evr.push('-');
1020 evr.push_str(r);
1021 }
1022
1023 Some(evr)
1024}
1025
1026fn parse_packager(packager: &str) -> (Option<String>, Option<String>) {
1027 if let Some(email_start) = packager.find('<') {
1028 let name = packager[..email_start].trim();
1029 if let Some(email_end) = packager.find('>') {
1030 let email = &packager[email_start + 1..email_end];
1031 return (Some(name.to_string()), Some(email.to_string()));
1032 }
1033 }
1034 (Some(packager.to_string()), None)
1035}
1036
1037fn build_rpm_purl(
1038 name: &str,
1039 version: Option<&str>,
1040 namespace: Option<&str>,
1041 architecture: Option<&str>,
1042 epoch: Option<&str>,
1043) -> Option<String> {
1044 use packageurl::PackageUrl;
1045
1046 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
1047
1048 if let Some(ns) = namespace {
1049 purl.with_namespace(ns).ok()?;
1050 }
1051
1052 if let Some(ver) = version {
1053 purl.with_version(ver).ok()?;
1054 }
1055
1056 if let Some(arch) = architecture {
1057 purl.add_qualifier("arch", arch).ok()?;
1058 }
1059
1060 if let Some(epoch) = epoch.filter(|epoch| !epoch.is_empty()) {
1062 purl.add_qualifier("epoch", epoch).ok()?;
1063 }
1064
1065 Some(purl.to_string())
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use std::fs;
1072 use std::path::PathBuf;
1073 use tempfile::NamedTempFile;
1074
1075 fn build_sparse_oversized_rpm(name: &str) -> PathBuf {
1076 let package = rpm::PackageBuilder::new(name, "1.0", "MIT", "x86_64", "Demo RPM package")
1077 .release("1")
1078 .build()
1079 .unwrap();
1080
1081 let temp_file = NamedTempFile::new().unwrap();
1082 package.write_file(temp_file.path()).unwrap();
1083 let oversized_len = MAX_MANIFEST_SIZE + 1_048_576;
1084 fs::OpenOptions::new()
1085 .write(true)
1086 .open(temp_file.path())
1087 .unwrap()
1088 .set_len(oversized_len)
1089 .unwrap();
1090
1091 temp_file.into_temp_path().keep().unwrap()
1092 }
1093
1094 #[test]
1095 fn test_rpm_parser_is_match() {
1096 assert!(RpmParser::is_match(&PathBuf::from("package.rpm")));
1097 assert!(RpmParser::is_match(&PathBuf::from("package.srpm")));
1098 assert!(RpmParser::is_match(&PathBuf::from(
1099 "test-1.0-1.el7.x86_64.rpm"
1100 )));
1101 assert!(!RpmParser::is_match(&PathBuf::from("package.deb")));
1102 assert!(!RpmParser::is_match(&PathBuf::from("package.tar.gz")));
1103 }
1104
1105 #[test]
1106 fn test_rpm_parser_matches_hash_named_source_rpm_by_magic() {
1107 let source_fixture = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1108 if !source_fixture.exists() {
1109 return;
1110 }
1111
1112 let temp_file = NamedTempFile::new().unwrap();
1113 fs::copy(&source_fixture, temp_file.path()).unwrap();
1114
1115 assert!(RpmParser::is_match(temp_file.path()));
1116 }
1117
1118 #[test]
1119 fn test_rpm_parser_matches_pack_named_rpm_by_magic() {
1120 let source_fixture = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1121 if !source_fixture.exists() {
1122 return;
1123 }
1124
1125 let temp_dir = tempfile::TempDir::new().unwrap();
1126 let pack_path = temp_dir.path().join("setup-2.5.49-b1.src.pack");
1127 fs::copy(&source_fixture, &pack_path).unwrap();
1128
1129 assert!(RpmParser::is_match(&pack_path));
1130 assert!(path_looks_like_rpm_archive(&pack_path));
1131 }
1132
1133 #[test]
1134 fn test_build_evr_version_simple() {
1135 let evr = "1.0-1";
1136 assert_eq!(evr, "1.0-1");
1137 }
1138
1139 #[test]
1140 fn test_build_evr_version_with_epoch() {
1141 let evr = "2:1.0-1";
1142 assert!(evr.starts_with("2:"));
1143 }
1144
1145 #[test]
1146 fn test_parse_packager() {
1147 let (name, email) = parse_packager("John Doe <john@example.com>");
1148 assert_eq!(name, Some("John Doe".to_string()));
1149 assert_eq!(email, Some("john@example.com".to_string()));
1150
1151 let (name2, email2) = parse_packager("Plain Name");
1152 assert_eq!(name2, Some("Plain Name".to_string()));
1153 assert_eq!(email2, None);
1154 }
1155
1156 #[test]
1157 fn test_build_rpm_purl() {
1158 let purl = build_rpm_purl(
1159 "bash",
1160 Some("4.4.19-1.el7"),
1161 Some("fedora"),
1162 Some("x86_64"),
1163 None,
1164 );
1165 assert!(purl.is_some());
1166 let purl_str = purl.unwrap();
1167 assert!(purl_str.contains("pkg:rpm/fedora/bash"));
1168 assert!(purl_str.contains("4.4.19-1.el7"));
1169 assert!(purl_str.contains("arch=x86_64"));
1170 }
1171
1172 #[test]
1173 fn test_parse_real_rpm() {
1174 let test_file = PathBuf::from("testdata/rpm/Eterm-0.9.3-5mdv2007.0.rpm");
1175 if !test_file.exists() {
1176 eprintln!("Warning: Test file not found, skipping test");
1177 return;
1178 }
1179
1180 let pkg = RpmParser::extract_first_package(&test_file);
1181
1182 assert_eq!(pkg.package_type, Some(PackageType::Rpm));
1183 assert_eq!(pkg.name, Some("Eterm".to_string()));
1184 assert_eq!(pkg.version, Some("0.9.3-5mdv2007.0".to_string()));
1185 }
1186
1187 #[test]
1188 fn test_parse_oversized_rpm_from_headers_only() {
1189 let test_file = build_sparse_oversized_rpm("oversized-demo");
1190
1191 assert!(RpmParser::is_match(&test_file));
1192
1193 let pkg = RpmParser::extract_first_package(&test_file);
1194
1195 assert_eq!(pkg.datasource_id, Some(DatasourceId::RpmArchive));
1196 assert_eq!(pkg.package_type, Some(PackageType::Rpm));
1197 assert_eq!(pkg.name.as_deref(), Some("oversized-demo"));
1198 assert_eq!(pkg.version.as_deref(), Some("1.0-1"));
1199
1200 fs::remove_file(test_file).unwrap();
1201 }
1202
1203 #[test]
1204 fn test_build_rpm_purl_no_namespace() {
1205 let purl = build_rpm_purl("package", Some("1.0-1"), None, Some("x86_64"), None);
1206 assert!(purl.is_some());
1207 let purl_str = purl.unwrap();
1208 assert!(purl_str.starts_with("pkg:rpm/package@"));
1209 assert!(purl_str.contains("arch=x86_64"));
1210 }
1211
1212 #[test]
1213 fn test_rpm_dependency_extraction() {
1214 use rpm::{Dependency as RpmDependency, DependencyFlags};
1215
1216 let rpm_dep = RpmDependency {
1217 name: "libc.so.6".to_string(),
1218 flags: DependencyFlags::GREATER | DependencyFlags::EQUAL,
1219 version: "2.2.5".to_string(),
1220 };
1221
1222 let formatted = format_rpm_requirement(&rpm_dep);
1223 assert_eq!(formatted, "libc.so.6 >= 2.2.5");
1224
1225 let rpm_dep_no_version = RpmDependency {
1226 name: "bash".to_string(),
1227 flags: DependencyFlags::ANY,
1228 version: String::new(),
1229 };
1230
1231 let formatted_no_ver = format_rpm_requirement(&rpm_dep_no_version);
1232 assert_eq!(formatted_no_ver, "bash");
1233 }
1234
1235 #[test]
1236 fn test_parse_packager_with_parentheses() {
1237 let (name, email) = parse_packager("John Doe (Company) <john@example.com>");
1238 assert_eq!(name, Some("John Doe (Company)".to_string()));
1239 assert_eq!(email, Some("john@example.com".to_string()));
1240 }
1241
1242 #[test]
1243 fn test_parse_packager_email_only() {
1244 let (name, email) = parse_packager("<noreply@example.com>");
1245 assert!(name.is_none() || name == Some(String::new()));
1246 assert_eq!(email, Some("noreply@example.com".to_string()));
1247 }
1248
1249 #[test]
1250 fn test_rpm_fping_package() {
1251 let test_file = PathBuf::from("testdata/rpm/fping-2.4b2-10.fc12.x86_64.rpm");
1252 if !test_file.exists() {
1253 return;
1254 }
1255
1256 let pkg = RpmParser::extract_first_package(&test_file);
1257 assert_eq!(pkg.name, Some("fping".to_string()));
1258 assert_eq!(pkg.version, Some("2.4b2-10.fc12".to_string()));
1259 }
1260
1261 #[test]
1262 fn test_rpm_archive_extracts_additional_metadata_fields() {
1263 let test_file = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1264 if !test_file.exists() {
1265 return;
1266 }
1267
1268 let pkg = RpmParser::extract_first_package(&test_file);
1269
1270 assert_eq!(pkg.name.as_deref(), Some("setup"));
1271 assert_eq!(
1272 pkg.qualifiers
1273 .as_ref()
1274 .and_then(|q| q.get("arch"))
1275 .map(String::as_str),
1276 Some("noarch")
1277 );
1278 assert!(!pkg.keywords.is_empty());
1279 assert!(pkg.size.is_some());
1280 assert!(
1281 pkg.parties
1282 .iter()
1283 .any(|party| party.role.as_deref() == Some("packager"))
1284 );
1285 assert_eq!(
1286 pkg.extra_data
1287 .as_ref()
1288 .and_then(|e| e.get("is_source"))
1289 .and_then(|v| v.as_bool()),
1290 Some(true)
1291 );
1292 }
1293
1294 #[test]
1295 fn test_source_rpm_sets_is_source_extra_data() {
1296 let test_file = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1297 if !test_file.exists() {
1298 return;
1299 }
1300
1301 let pkg = RpmParser::extract_first_package(&test_file);
1302
1303 assert_eq!(
1306 pkg.extra_data
1307 .as_ref()
1308 .and_then(|e| e.get("is_source"))
1309 .and_then(|v| v.as_bool()),
1310 Some(true)
1311 );
1312 assert!(
1313 pkg.purl
1314 .as_ref()
1315 .is_some_and(|purl| !purl.contains("source=true"))
1316 );
1317 }
1318
1319 #[test]
1320 fn test_rpm_archive_extracts_vcs_and_source_metadata() {
1321 let package = rpm::PackageBuilder::new(
1322 "thunar-sendto-clamtk",
1323 "0.08",
1324 "GPL-2.0-or-later",
1325 "noarch",
1326 "Simple virus scanning extension for Thunar",
1327 )
1328 .release("2.fc40")
1329 .vendor("Fedora Project")
1330 .packager("Fedora Release Engineering <releng@fedoraproject.org>")
1331 .group("Applications/System")
1332 .vcs("git+https://src.fedoraproject.org/rpms/thunar-sendto-clamtk.git#5a3f8e92b45f46b464e6924c79d4bf3e11bb1f0e")
1333 .build()
1334 .unwrap();
1335
1336 let temp_file = NamedTempFile::new().unwrap();
1337 package.write_file(temp_file.path()).unwrap();
1338
1339 let pkg = RpmParser::extract_first_package(temp_file.path());
1340
1341 assert_eq!(pkg.namespace.as_deref(), Some("fedora"));
1342 assert_eq!(
1343 pkg.vcs_url.as_deref(),
1344 Some(
1345 "git+https://src.fedoraproject.org/rpms/thunar-sendto-clamtk.git#5a3f8e92b45f46b464e6924c79d4bf3e11bb1f0e",
1346 )
1347 );
1348 assert!(
1349 pkg.extra_data
1350 .as_ref()
1351 .is_some_and(|extra| extra.contains_key("build_time"))
1352 );
1353 assert!(!pkg.keywords.is_empty());
1354 }
1355
1356 #[test]
1357 fn test_rpm_archive_preserves_provides_and_obsoletes_relationships() {
1358 use rpm::{Dependency as RpmDependency, DependencyFlags};
1359
1360 let package = rpm::PackageBuilder::new(
1361 "demo-rpm",
1362 "1.0.0",
1363 "MIT",
1364 "noarch",
1365 "RPM relationship metadata fixture",
1366 )
1367 .release("1")
1368 .provides(RpmDependency {
1369 name: "demo-rpm-virtual".to_string(),
1370 flags: DependencyFlags::GREATER | DependencyFlags::EQUAL,
1371 version: "1.0.0".to_string(),
1372 })
1373 .obsoletes(RpmDependency {
1374 name: "old-demo-rpm".to_string(),
1375 flags: DependencyFlags::LESS,
1376 version: "0.9.0".to_string(),
1377 })
1378 .build()
1379 .unwrap();
1380
1381 let temp_file = NamedTempFile::new().unwrap();
1382 package.write_file(temp_file.path()).unwrap();
1383
1384 let pkg = RpmParser::extract_first_package(temp_file.path());
1385 let extra = pkg.extra_data.as_ref().expect("extra_data should exist");
1386
1387 let provides = extra
1388 .get("provides")
1389 .and_then(|value| value.as_array())
1390 .expect("provides should be present");
1391 assert!(
1392 provides
1393 .iter()
1394 .any(|value| value.as_str() == Some("demo-rpm-virtual >= 1.0.0"))
1395 );
1396
1397 let obsoletes = extra
1398 .get("obsoletes")
1399 .and_then(|value| value.as_array())
1400 .expect("obsoletes should be present");
1401 assert!(
1402 obsoletes
1403 .iter()
1404 .any(|value| value.as_str() == Some("old-demo-rpm < 0.9.0"))
1405 );
1406 }
1407
1408 #[test]
1409 fn test_rpm_archive_normalizes_declared_license_expression() {
1410 let package = rpm::PackageBuilder::new(
1411 "demo-license",
1412 "1.0.0",
1413 "LGPLv2",
1414 "noarch",
1415 "RPM declared license normalization fixture",
1416 )
1417 .release("1")
1418 .build()
1419 .unwrap();
1420
1421 let temp_file = NamedTempFile::new().unwrap();
1422 package.write_file(temp_file.path()).unwrap();
1423
1424 let pkg = RpmParser::extract_first_package(temp_file.path());
1425
1426 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("LGPLv2"));
1427 assert_eq!(pkg.declared_license_expression.as_deref(), Some("lgpl-2.0"));
1428 assert_eq!(
1429 pkg.declared_license_expression_spdx.as_deref(),
1430 Some("LGPL-2.0-only")
1431 );
1432 assert_eq!(pkg.license_detections.len(), 1);
1433 assert_eq!(
1434 pkg.license_detections[0].license_expression_spdx,
1435 "LGPL-2.0-only"
1436 );
1437 assert_eq!(
1438 pkg.license_detections[0].matches[0].matched_text.as_deref(),
1439 Some("LGPLv2")
1440 );
1441 }
1442
1443 #[test]
1444 fn test_rpm_archive_normalizes_public_domain_declared_license_expression() {
1445 let package = rpm::PackageBuilder::new(
1446 "demo-public-domain",
1447 "1.0.0",
1448 "public domain",
1449 "noarch",
1450 "RPM public domain normalization fixture",
1451 )
1452 .release("1")
1453 .build()
1454 .unwrap();
1455
1456 let temp_file = NamedTempFile::new().unwrap();
1457 package.write_file(temp_file.path()).unwrap();
1458
1459 let pkg = RpmParser::extract_first_package(temp_file.path());
1460
1461 assert_eq!(
1462 pkg.extracted_license_statement.as_deref(),
1463 Some("public domain")
1464 );
1465 assert_eq!(
1466 pkg.declared_license_expression.as_deref(),
1467 Some("licenseref-scancode-public-domain")
1468 );
1469 assert_eq!(
1470 pkg.declared_license_expression_spdx.as_deref(),
1471 Some("LicenseRef-scancode-public-domain")
1472 );
1473 assert_eq!(pkg.license_detections.len(), 1);
1474 }
1475
1476 #[test]
1477 fn test_normalize_rpm_declared_license_rewrites_compound_aliases() {
1478 let normalized = normalize_rpm_declared_license("BSD and GPLv2+")
1479 .expect("compound RPM license should normalize");
1480
1481 assert_eq!(
1482 normalized.declared_license_expression_spdx,
1483 "BSD-3-Clause AND GPL-2.0-or-later"
1484 );
1485 }
1486}