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
512 .source_rpm
513 .as_deref()
514 .and_then(super::rpm_db::source_rpm_purl)
515 .into_iter()
516 .collect(),
517 extra_data: (!extra_data.is_empty()).then_some(extra_data),
518 purl: build_rpm_purl(
519 &name,
520 version.as_deref(),
521 namespace.as_deref(),
522 fields.arch.as_deref(),
523 epoch.as_deref(),
524 )
525 .map(truncate_field),
526 ..Default::default()
527 })
528}
529
530pub(crate) fn extract_rpm_packages(path: &Path) -> Vec<PackageData> {
531 if let Err(e) = fs::metadata(path) {
532 warn!("Cannot stat RPM file {:?}: {}", path, e);
533 return vec![default_package_data()];
534 }
535
536 let metadata = match parse_rpm_metadata_only(path) {
537 Ok(metadata) => metadata,
538 Err(message) => {
539 if let Some(package) = salvage_rpm_header_fields(path)
540 .and_then(|fields| build_salvaged_rpm_package(path, fields))
541 {
542 return vec![package];
543 }
544 warn!("{}", message);
545 return vec![default_package_data()];
546 }
547 };
548
549 vec![parse_rpm_package(&metadata, path)]
550}
551
552pub struct RpmParser;
554
555impl PackageParser for RpmParser {
556 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
557
558 fn is_match(path: &Path) -> bool {
559 path_looks_like_rpm_archive(path)
560 }
561
562 fn extract_packages(path: &Path) -> Vec<PackageData> {
563 extract_rpm_packages(path)
564 }
565
566 fn metadata() -> Vec<super::metadata::ParserMetadata> {
567 vec![super::metadata::ParserMetadata {
568 description: "RPM package archive",
569 file_patterns: &["**/*.rpm", "**/*.srpm"],
570 package_type: "rpm",
571 primary_language: "",
572 documentation_url: Some("https://rpm.org/"),
573 }]
574 }
575}
576
577pub(crate) fn infer_rpm_namespace_from_filename(path: &Path) -> Option<String> {
578 let filename = path.file_name()?.to_str()?.to_ascii_lowercase();
579
580 if filename.contains(".fc") {
581 return Some("fedora".to_string());
582 }
583 if filename.contains(".el") {
584 return Some("rhel".to_string());
585 }
586 if filename.contains("mdv") || filename.contains("mnb") {
587 return Some("openmandriva".to_string());
588 }
589 if filename.contains("opensuse") {
590 return Some("opensuse".to_string());
591 }
592 if filename.contains("suse") {
593 return Some("suse".to_string());
594 }
595
596 None
597}
598
599fn parse_rpm_package(metadata: &PackageMetadata, path: &Path) -> PackageData {
600 let name = metadata
601 .get_name()
602 .ok()
603 .map(|s| truncate_field(s.to_string()));
604 let version = build_evr_version(metadata).map(truncate_field);
605 let epoch = metadata
608 .get_epoch()
609 .ok()
610 .filter(|epoch| *epoch > 0)
611 .map(|epoch| epoch.to_string());
612 let description = metadata
613 .get_description()
614 .ok()
615 .map(|s| truncate_field(s.to_string()));
616 let homepage_url = metadata
617 .get_url()
618 .ok()
619 .map(|s| truncate_field(s.to_string()));
620 let architecture = metadata
621 .get_arch()
622 .ok()
623 .map(|s| truncate_field(s.to_string()));
624 let path_str = path.to_string_lossy();
625 let is_source = metadata.is_source_package()
626 || path_str.ends_with(".src.rpm")
627 || path_str.ends_with(".srpm");
628 let distribution =
629 rpm_header_string(metadata, IndexTag::RPMTAG_DISTRIBUTION).map(truncate_field);
630 let dist_url = rpm_header_string(metadata, IndexTag::RPMTAG_DISTURL).map(truncate_field);
631 let bug_tracking_url = rpm_header_string(metadata, IndexTag::RPMTAG_BUGURL).map(truncate_field);
632 let source_urls =
633 rpm_header_string_array(metadata, IndexTag::RPMTAG_SOURCE).unwrap_or_default();
634 let source_rpm = metadata
635 .get_source_rpm()
636 .ok()
637 .filter(|value| !value.is_empty())
638 .map(|value| truncate_field(value.to_string()));
639 let namespace = infer_rpm_namespace(
640 distribution.as_deref(),
641 metadata.get_vendor().ok(),
642 metadata.get_release().ok(),
643 dist_url.as_deref(),
644 )
645 .or_else(|| infer_rpm_namespace_from_filename(path))
646 .map(truncate_field);
647
648 let mut parties = Vec::new();
649
650 if let Ok(vendor) = metadata.get_vendor()
651 && !vendor.is_empty()
652 {
653 parties.push(Party {
654 r#type: Some(PartyType::Organization),
655 role: Some("vendor".to_string()),
656 name: Some(truncate_field(vendor.to_string())),
657 email: None,
658 url: None,
659 organization: None,
660 organization_url: None,
661 timezone: None,
662 });
663 }
664
665 if let Some(distribution_name) = distribution.as_ref() {
666 parties.push(Party {
667 r#type: Some(PartyType::Organization),
668 role: Some("distributor".to_string()),
669 name: Some(distribution_name.clone()),
670 email: None,
671 url: None,
672 organization: None,
673 organization_url: None,
674 timezone: None,
675 });
676 }
677
678 if let Ok(packager) = metadata.get_packager()
679 && !packager.is_empty()
680 {
681 let (name_opt, email_opt) = parse_packager(packager);
682 parties.push(Party {
683 r#type: Some(PartyType::Person),
684 role: Some("packager".to_string()),
685 name: name_opt.map(truncate_field),
686 email: email_opt.map(truncate_field),
687 url: None,
688 organization: None,
689 organization_url: None,
690 timezone: None,
691 });
692 }
693
694 let extracted_license_statement = metadata
695 .get_license()
696 .ok()
697 .map(|s| truncate_field(s.to_string()));
698 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
699 extracted_license_statement
700 .as_deref()
701 .and_then(normalize_rpm_declared_license)
702 .map(|normalized| {
703 build_declared_license_data(
704 normalized,
705 DeclaredLicenseMatchMetadata::single_line(
706 extracted_license_statement.as_deref().unwrap_or_default(),
707 ),
708 )
709 })
710 .map(|(expr, spdx, detections)| {
711 (
712 expr.map(truncate_field),
713 spdx.map(truncate_field),
714 detections,
715 )
716 })
717 .unwrap_or_else(empty_declared_license_data);
718
719 let dependencies = extract_rpm_dependencies(metadata, namespace.as_deref());
720
721 let qualifiers = build_rpm_qualifiers(architecture.as_deref(), epoch.as_deref());
722
723 let mut keywords = Vec::new();
724 if let Ok(group) = metadata.get_group()
725 && !group.is_empty()
726 {
727 keywords.push(truncate_field(group.to_string()));
728 }
729
730 let mut extra_data = std::collections::HashMap::new();
731 if is_source {
733 extra_data.insert("is_source".to_string(), serde_json::Value::Bool(true));
734 }
735 if let Some(distribution) = distribution.clone() {
736 extra_data.insert(
737 "distribution".to_string(),
738 serde_json::Value::String(distribution),
739 );
740 }
741 if let Some(dist_url) = dist_url.clone() {
742 extra_data.insert("dist_url".to_string(), serde_json::Value::String(dist_url));
743 }
744 if let Ok(build_host) = metadata.get_build_host()
745 && !build_host.is_empty()
746 {
747 extra_data.insert(
748 "build_host".to_string(),
749 serde_json::Value::String(build_host.to_string()),
750 );
751 }
752 if let Ok(build_time) = metadata.get_build_time() {
753 extra_data.insert(
754 "build_time".to_string(),
755 serde_json::Value::Number(serde_json::Number::from(build_time)),
756 );
757 }
758 if !source_urls.is_empty() {
759 extra_data.insert(
760 "source_urls".to_string(),
761 serde_json::Value::Array(
762 source_urls
763 .iter()
764 .cloned()
765 .map(serde_json::Value::String)
766 .collect(),
767 ),
768 );
769 }
770 if let Some(provides) = extract_rpm_relationships(metadata, RpmRelationshipKind::Provides)
771 && !provides.is_empty()
772 {
773 extra_data.insert(
774 "provides".to_string(),
775 serde_json::Value::Array(
776 provides
777 .into_iter()
778 .map(serde_json::Value::String)
779 .collect(),
780 ),
781 );
782 }
783 if let Some(obsoletes) = extract_rpm_relationships(metadata, RpmRelationshipKind::Obsoletes)
784 && !obsoletes.is_empty()
785 {
786 extra_data.insert(
787 "obsoletes".to_string(),
788 serde_json::Value::Array(
789 obsoletes
790 .into_iter()
791 .map(serde_json::Value::String)
792 .collect(),
793 ),
794 );
795 }
796 let vcs_url = infer_vcs_url(metadata, &source_urls).map(truncate_field);
797
798 PackageData {
799 datasource_id: Some(DatasourceId::RpmArchive),
800 package_type: Some(PACKAGE_TYPE),
801 namespace: namespace.clone(),
802 name: name.clone(),
803 version: version.clone(),
804 qualifiers,
805 description,
806 homepage_url,
807 size: metadata.get_installed_size().ok(),
808 parties,
809 keywords,
810 bug_tracking_url,
811 declared_license_expression,
812 declared_license_expression_spdx,
813 license_detections,
814 extracted_license_statement,
815 dependencies,
816 source_packages: source_rpm
817 .as_deref()
818 .and_then(super::rpm_db::source_rpm_purl)
819 .into_iter()
820 .collect(),
821 vcs_url,
822 extra_data: (!extra_data.is_empty()).then_some(extra_data),
823 purl: name.as_ref().and_then(|n| {
824 build_rpm_purl(
825 n,
826 version.as_deref(),
827 namespace.as_deref(),
828 architecture.as_deref(),
829 epoch.as_deref(),
830 )
831 .map(truncate_field)
832 }),
833 ..Default::default()
834 }
835}
836
837pub(crate) fn normalize_rpm_declared_license(statement: &str) -> Option<NormalizedDeclaredLicense> {
838 let trimmed = statement.trim();
839 if trimmed.is_empty() {
840 return None;
841 }
842
843 let rewritten = canonicalize_rpm_license_statement(trimmed);
844 if let Some(normalized) = normalize_spdx_expression(&rewritten) {
845 return Some(normalized);
846 }
847
848 let is_simple_key = !trimmed.contains(' ')
849 && !trimmed.contains(',')
850 && !trimmed.contains('(')
851 && !trimmed.contains(')');
852 if is_simple_key {
853 return normalize_declared_license_key(trimmed);
854 }
855
856 None
857}
858
859fn canonicalize_rpm_license_statement(statement: &str) -> String {
860 let mut rewritten = statement.trim().to_string();
861
862 for (from, to) in [
863 ("LGPLv2.1+", "LGPL-2.1-or-later"),
864 ("LGPLv2.1", "LGPL-2.1-only"),
865 ("LGPLv2+", "LGPL-2.0-or-later"),
866 ("LGPLv2", "LGPL-2.0-only"),
867 ("LGPLv3+", "LGPL-3.0-or-later"),
868 ("LGPLv3", "LGPL-3.0-only"),
869 ("GPLv2+", "GPL-2.0-or-later"),
870 ("GPLv2", "GPL-2.0-only"),
871 ("GPLv3+", "GPL-3.0-or-later"),
872 ("GPLv3", "GPL-3.0-only"),
873 ("GPLV2+", "GPL-2.0-or-later"),
874 ("MPLv2.0", "MPL-2.0"),
875 ("MPLv1.1", "MPL-1.1"),
876 ("BSD with advertising", "BSD-4-Clause-UC"),
877 ("Public Domain", "LicenseRef-scancode-public-domain"),
878 ("public domain", "LicenseRef-scancode-public-domain"),
879 ("OpenLDAP", "OLDAP-2.8"),
880 ("OpenSSL", "OpenSSL"),
881 ("Sleepycat", "Sleepycat"),
882 ("zlib", "Zlib"),
883 ("Boost", "BSL-1.0"),
884 ("BSD", "BSD-3-Clause"),
885 ] {
886 rewritten = rewritten.replace(from, to);
887 }
888
889 rewritten = RE_RPM_LICENSE_WITH_EXCEPTIONS
890 .replace_all(&rewritten, "")
891 .into_owned();
892 rewritten = RE_RPM_LICENSE_COMMA
893 .replace_all(&rewritten, " AND ")
894 .into_owned();
895 rewritten = RE_RPM_LICENSE_AND
896 .replace_all(&rewritten, " AND ")
897 .into_owned();
898 rewritten = RE_RPM_LICENSE_OR
899 .replace_all(&rewritten, " OR ")
900 .into_owned();
901
902 rewritten.split_whitespace().collect::<Vec<_>>().join(" ")
903}
904
905fn extract_rpm_dependencies(
906 metadata: &PackageMetadata,
907 namespace: Option<&str>,
908) -> Vec<Dependency> {
909 let mut dependencies = Vec::new();
910
911 if let Ok(requires) = metadata.get_requires() {
912 for rpm_dep in requires {
913 if dependencies.len() >= MAX_ITERATION_COUNT {
914 warn!(
915 "RPM dependency iteration capped at {} items",
916 MAX_ITERATION_COUNT
917 );
918 break;
919 }
920 let purl = build_rpm_purl(
921 &rpm_dep.name,
922 if rpm_dep.version.is_empty() {
923 None
924 } else {
925 Some(&rpm_dep.version)
926 },
927 namespace,
928 None,
929 None,
930 )
931 .map(truncate_field);
932
933 let extracted_requirement = if !rpm_dep.version.is_empty() {
934 Some(truncate_field(format_rpm_requirement(&rpm_dep)))
935 } else {
936 None
937 };
938
939 dependencies.push(Dependency {
940 purl,
941 extracted_requirement,
942 scope: Some("install".to_string()),
943 is_runtime: Some(true),
944 is_optional: Some(false),
945 is_direct: Some(true),
946 resolved_package: None,
947 extra_data: None,
948 is_pinned: Some(!rpm_dep.version.is_empty()),
949 });
950 }
951 }
952
953 dependencies
954}
955
956enum RpmRelationshipKind {
957 Provides,
958 Obsoletes,
959}
960
961fn extract_rpm_relationships(
962 metadata: &PackageMetadata,
963 kind: RpmRelationshipKind,
964) -> Option<Vec<String>> {
965 let relationships = match kind {
966 RpmRelationshipKind::Provides => metadata.get_provides().ok()?,
967 RpmRelationshipKind::Obsoletes => metadata.get_obsoletes().ok()?,
968 };
969
970 let mut count = 0usize;
971 let values: Vec<String> = relationships
972 .into_iter()
973 .take(MAX_ITERATION_COUNT)
974 .map(|dep| format_rpm_requirement(&dep))
975 .filter(|value| !value.is_empty() && value != "(none)")
976 .inspect(|_| count += 1)
977 .collect();
978
979 if count >= MAX_ITERATION_COUNT {
980 warn!(
981 "RPM relationship iteration capped at {} items",
982 MAX_ITERATION_COUNT
983 );
984 }
985
986 (!values.is_empty()).then_some(values)
987}
988
989fn format_rpm_requirement(dep: &rpm::Dependency) -> String {
990 use rpm::DependencyFlags;
991
992 if dep.version.is_empty() {
993 return dep.name.clone();
994 }
995
996 let operator = if dep.flags.contains(DependencyFlags::EQUAL)
997 && dep.flags.contains(DependencyFlags::LESS)
998 {
999 "<="
1000 } else if dep.flags.contains(DependencyFlags::EQUAL)
1001 && dep.flags.contains(DependencyFlags::GREATER)
1002 {
1003 ">="
1004 } else if dep.flags.contains(DependencyFlags::EQUAL) {
1005 "="
1006 } else if dep.flags.contains(DependencyFlags::LESS) {
1007 "<"
1008 } else if dep.flags.contains(DependencyFlags::GREATER) {
1009 ">"
1010 } else {
1011 ""
1012 };
1013
1014 if operator.is_empty() {
1015 dep.name.clone()
1016 } else {
1017 format!("{} {} {}", dep.name, operator, dep.version)
1018 }
1019}
1020
1021fn build_evr_version(metadata: &PackageMetadata) -> Option<String> {
1022 let version = metadata.get_version().ok()?;
1023 let release = metadata.get_release().ok();
1024
1025 let mut evr = String::from(version);
1026
1027 if let Some(r) = release {
1028 evr.push('-');
1029 evr.push_str(r);
1030 }
1031
1032 Some(evr)
1033}
1034
1035fn parse_packager(packager: &str) -> (Option<String>, Option<String>) {
1036 if let Some(email_start) = packager.find('<') {
1037 let name = packager[..email_start].trim();
1038 if let Some(email_end) = packager.find('>') {
1039 let email = &packager[email_start + 1..email_end];
1040 return (Some(name.to_string()), Some(email.to_string()));
1041 }
1042 }
1043 (Some(packager.to_string()), None)
1044}
1045
1046fn build_rpm_purl(
1047 name: &str,
1048 version: Option<&str>,
1049 namespace: Option<&str>,
1050 architecture: Option<&str>,
1051 epoch: Option<&str>,
1052) -> Option<String> {
1053 use packageurl::PackageUrl;
1054
1055 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
1056
1057 if let Some(ns) = namespace {
1058 purl.with_namespace(ns).ok()?;
1059 }
1060
1061 if let Some(ver) = version {
1062 purl.with_version(ver).ok()?;
1063 }
1064
1065 if let Some(arch) = architecture {
1066 purl.add_qualifier("arch", arch).ok()?;
1067 }
1068
1069 if let Some(epoch) = epoch.filter(|epoch| !epoch.is_empty()) {
1071 purl.add_qualifier("epoch", epoch).ok()?;
1072 }
1073
1074 Some(purl.to_string())
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080 use std::fs;
1081 use std::path::PathBuf;
1082 use tempfile::NamedTempFile;
1083
1084 fn build_sparse_oversized_rpm(name: &str) -> PathBuf {
1085 let package = rpm::PackageBuilder::new(name, "1.0", "MIT", "x86_64", "Demo RPM package")
1086 .release("1")
1087 .build()
1088 .unwrap();
1089
1090 let temp_file = NamedTempFile::new().unwrap();
1091 package.write_file(temp_file.path()).unwrap();
1092 let oversized_len = MAX_MANIFEST_SIZE + 1_048_576;
1093 fs::OpenOptions::new()
1094 .write(true)
1095 .open(temp_file.path())
1096 .unwrap()
1097 .set_len(oversized_len)
1098 .unwrap();
1099
1100 temp_file.into_temp_path().keep().unwrap()
1101 }
1102
1103 #[test]
1104 fn test_rpm_parser_is_match() {
1105 assert!(RpmParser::is_match(&PathBuf::from("package.rpm")));
1106 assert!(RpmParser::is_match(&PathBuf::from("package.srpm")));
1107 assert!(RpmParser::is_match(&PathBuf::from(
1108 "test-1.0-1.el7.x86_64.rpm"
1109 )));
1110 assert!(!RpmParser::is_match(&PathBuf::from("package.deb")));
1111 assert!(!RpmParser::is_match(&PathBuf::from("package.tar.gz")));
1112 }
1113
1114 #[test]
1115 fn test_rpm_parser_matches_hash_named_source_rpm_by_magic() {
1116 let source_fixture = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1117 if !source_fixture.exists() {
1118 return;
1119 }
1120
1121 let temp_file = NamedTempFile::new().unwrap();
1122 fs::copy(&source_fixture, temp_file.path()).unwrap();
1123
1124 assert!(RpmParser::is_match(temp_file.path()));
1125 }
1126
1127 #[test]
1128 fn test_rpm_parser_matches_pack_named_rpm_by_magic() {
1129 let source_fixture = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1130 if !source_fixture.exists() {
1131 return;
1132 }
1133
1134 let temp_dir = tempfile::TempDir::new().unwrap();
1135 let pack_path = temp_dir.path().join("setup-2.5.49-b1.src.pack");
1136 fs::copy(&source_fixture, &pack_path).unwrap();
1137
1138 assert!(RpmParser::is_match(&pack_path));
1139 assert!(path_looks_like_rpm_archive(&pack_path));
1140 }
1141
1142 #[test]
1143 fn test_build_evr_version_simple() {
1144 let evr = "1.0-1";
1145 assert_eq!(evr, "1.0-1");
1146 }
1147
1148 #[test]
1149 fn test_build_evr_version_with_epoch() {
1150 let evr = "2:1.0-1";
1151 assert!(evr.starts_with("2:"));
1152 }
1153
1154 #[test]
1155 fn test_parse_packager() {
1156 let (name, email) = parse_packager("John Doe <john@example.com>");
1157 assert_eq!(name, Some("John Doe".to_string()));
1158 assert_eq!(email, Some("john@example.com".to_string()));
1159
1160 let (name2, email2) = parse_packager("Plain Name");
1161 assert_eq!(name2, Some("Plain Name".to_string()));
1162 assert_eq!(email2, None);
1163 }
1164
1165 #[test]
1166 fn test_build_rpm_purl() {
1167 let purl = build_rpm_purl(
1168 "bash",
1169 Some("4.4.19-1.el7"),
1170 Some("fedora"),
1171 Some("x86_64"),
1172 None,
1173 );
1174 assert!(purl.is_some());
1175 let purl_str = purl.unwrap();
1176 assert!(purl_str.contains("pkg:rpm/fedora/bash"));
1177 assert!(purl_str.contains("4.4.19-1.el7"));
1178 assert!(purl_str.contains("arch=x86_64"));
1179 }
1180
1181 #[test]
1182 fn test_parse_real_rpm() {
1183 let test_file = PathBuf::from("testdata/rpm/Eterm-0.9.3-5mdv2007.0.rpm");
1184 if !test_file.exists() {
1185 eprintln!("Warning: Test file not found, skipping test");
1186 return;
1187 }
1188
1189 let pkg = RpmParser::extract_first_package(&test_file);
1190
1191 assert_eq!(pkg.package_type, Some(PackageType::Rpm));
1192 assert_eq!(pkg.name, Some("Eterm".to_string()));
1193 assert_eq!(pkg.version, Some("0.9.3-5mdv2007.0".to_string()));
1194 }
1195
1196 #[test]
1197 fn test_parse_oversized_rpm_from_headers_only() {
1198 let test_file = build_sparse_oversized_rpm("oversized-demo");
1199
1200 assert!(RpmParser::is_match(&test_file));
1201
1202 let pkg = RpmParser::extract_first_package(&test_file);
1203
1204 assert_eq!(pkg.datasource_id, Some(DatasourceId::RpmArchive));
1205 assert_eq!(pkg.package_type, Some(PackageType::Rpm));
1206 assert_eq!(pkg.name.as_deref(), Some("oversized-demo"));
1207 assert_eq!(pkg.version.as_deref(), Some("1.0-1"));
1208
1209 fs::remove_file(test_file).unwrap();
1210 }
1211
1212 #[test]
1213 fn test_build_rpm_purl_no_namespace() {
1214 let purl = build_rpm_purl("package", Some("1.0-1"), None, Some("x86_64"), None);
1215 assert!(purl.is_some());
1216 let purl_str = purl.unwrap();
1217 assert!(purl_str.starts_with("pkg:rpm/package@"));
1218 assert!(purl_str.contains("arch=x86_64"));
1219 }
1220
1221 #[test]
1222 fn test_rpm_dependency_extraction() {
1223 use rpm::{Dependency as RpmDependency, DependencyFlags};
1224
1225 let rpm_dep = RpmDependency {
1226 name: "libc.so.6".to_string(),
1227 flags: DependencyFlags::GREATER | DependencyFlags::EQUAL,
1228 version: "2.2.5".to_string(),
1229 };
1230
1231 let formatted = format_rpm_requirement(&rpm_dep);
1232 assert_eq!(formatted, "libc.so.6 >= 2.2.5");
1233
1234 let rpm_dep_no_version = RpmDependency {
1235 name: "bash".to_string(),
1236 flags: DependencyFlags::ANY,
1237 version: String::new(),
1238 };
1239
1240 let formatted_no_ver = format_rpm_requirement(&rpm_dep_no_version);
1241 assert_eq!(formatted_no_ver, "bash");
1242 }
1243
1244 #[test]
1245 fn test_parse_packager_with_parentheses() {
1246 let (name, email) = parse_packager("John Doe (Company) <john@example.com>");
1247 assert_eq!(name, Some("John Doe (Company)".to_string()));
1248 assert_eq!(email, Some("john@example.com".to_string()));
1249 }
1250
1251 #[test]
1252 fn test_parse_packager_email_only() {
1253 let (name, email) = parse_packager("<noreply@example.com>");
1254 assert!(name.is_none() || name == Some(String::new()));
1255 assert_eq!(email, Some("noreply@example.com".to_string()));
1256 }
1257
1258 #[test]
1259 fn test_rpm_fping_package() {
1260 let test_file = PathBuf::from("testdata/rpm/fping-2.4b2-10.fc12.x86_64.rpm");
1261 if !test_file.exists() {
1262 return;
1263 }
1264
1265 let pkg = RpmParser::extract_first_package(&test_file);
1266 assert_eq!(pkg.name, Some("fping".to_string()));
1267 assert_eq!(pkg.version, Some("2.4b2-10.fc12".to_string()));
1268 }
1269
1270 #[test]
1271 fn test_rpm_archive_extracts_additional_metadata_fields() {
1272 let test_file = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1273 if !test_file.exists() {
1274 return;
1275 }
1276
1277 let pkg = RpmParser::extract_first_package(&test_file);
1278
1279 assert_eq!(pkg.name.as_deref(), Some("setup"));
1280 assert_eq!(
1281 pkg.qualifiers
1282 .as_ref()
1283 .and_then(|q| q.get("arch"))
1284 .map(String::as_str),
1285 Some("noarch")
1286 );
1287 assert!(!pkg.keywords.is_empty());
1288 assert!(pkg.size.is_some());
1289 assert!(
1290 pkg.parties
1291 .iter()
1292 .any(|party| party.role.as_deref() == Some("packager"))
1293 );
1294 assert_eq!(
1295 pkg.extra_data
1296 .as_ref()
1297 .and_then(|e| e.get("is_source"))
1298 .and_then(|v| v.as_bool()),
1299 Some(true)
1300 );
1301 }
1302
1303 #[test]
1304 fn test_source_rpm_sets_is_source_extra_data() {
1305 let test_file = PathBuf::from("testdata/rpm/setup-2.5.49-b1.src.rpm");
1306 if !test_file.exists() {
1307 return;
1308 }
1309
1310 let pkg = RpmParser::extract_first_package(&test_file);
1311
1312 assert_eq!(
1315 pkg.extra_data
1316 .as_ref()
1317 .and_then(|e| e.get("is_source"))
1318 .and_then(|v| v.as_bool()),
1319 Some(true)
1320 );
1321 assert!(
1322 pkg.purl
1323 .as_ref()
1324 .is_some_and(|purl| !purl.contains("source=true"))
1325 );
1326 }
1327
1328 #[test]
1329 fn test_rpm_archive_extracts_vcs_and_source_metadata() {
1330 let package = rpm::PackageBuilder::new(
1331 "thunar-sendto-clamtk",
1332 "0.08",
1333 "GPL-2.0-or-later",
1334 "noarch",
1335 "Simple virus scanning extension for Thunar",
1336 )
1337 .release("2.fc40")
1338 .vendor("Fedora Project")
1339 .packager("Fedora Release Engineering <releng@fedoraproject.org>")
1340 .group("Applications/System")
1341 .vcs("git+https://src.fedoraproject.org/rpms/thunar-sendto-clamtk.git#5a3f8e92b45f46b464e6924c79d4bf3e11bb1f0e")
1342 .build()
1343 .unwrap();
1344
1345 let temp_file = NamedTempFile::new().unwrap();
1346 package.write_file(temp_file.path()).unwrap();
1347
1348 let pkg = RpmParser::extract_first_package(temp_file.path());
1349
1350 assert_eq!(pkg.namespace.as_deref(), Some("fedora"));
1351 assert_eq!(
1352 pkg.vcs_url.as_deref(),
1353 Some(
1354 "git+https://src.fedoraproject.org/rpms/thunar-sendto-clamtk.git#5a3f8e92b45f46b464e6924c79d4bf3e11bb1f0e",
1355 )
1356 );
1357 assert!(
1358 pkg.extra_data
1359 .as_ref()
1360 .is_some_and(|extra| extra.contains_key("build_time"))
1361 );
1362 assert!(!pkg.keywords.is_empty());
1363 }
1364
1365 #[test]
1366 fn test_rpm_archive_preserves_provides_and_obsoletes_relationships() {
1367 use rpm::{Dependency as RpmDependency, DependencyFlags};
1368
1369 let package = rpm::PackageBuilder::new(
1370 "demo-rpm",
1371 "1.0.0",
1372 "MIT",
1373 "noarch",
1374 "RPM relationship metadata fixture",
1375 )
1376 .release("1")
1377 .provides(RpmDependency {
1378 name: "demo-rpm-virtual".to_string(),
1379 flags: DependencyFlags::GREATER | DependencyFlags::EQUAL,
1380 version: "1.0.0".to_string(),
1381 })
1382 .obsoletes(RpmDependency {
1383 name: "old-demo-rpm".to_string(),
1384 flags: DependencyFlags::LESS,
1385 version: "0.9.0".to_string(),
1386 })
1387 .build()
1388 .unwrap();
1389
1390 let temp_file = NamedTempFile::new().unwrap();
1391 package.write_file(temp_file.path()).unwrap();
1392
1393 let pkg = RpmParser::extract_first_package(temp_file.path());
1394 let extra = pkg.extra_data.as_ref().expect("extra_data should exist");
1395
1396 let provides = extra
1397 .get("provides")
1398 .and_then(|value| value.as_array())
1399 .expect("provides should be present");
1400 assert!(
1401 provides
1402 .iter()
1403 .any(|value| value.as_str() == Some("demo-rpm-virtual >= 1.0.0"))
1404 );
1405
1406 let obsoletes = extra
1407 .get("obsoletes")
1408 .and_then(|value| value.as_array())
1409 .expect("obsoletes should be present");
1410 assert!(
1411 obsoletes
1412 .iter()
1413 .any(|value| value.as_str() == Some("old-demo-rpm < 0.9.0"))
1414 );
1415 }
1416
1417 #[test]
1418 fn test_rpm_archive_normalizes_declared_license_expression() {
1419 let package = rpm::PackageBuilder::new(
1420 "demo-license",
1421 "1.0.0",
1422 "LGPLv2",
1423 "noarch",
1424 "RPM declared license normalization fixture",
1425 )
1426 .release("1")
1427 .build()
1428 .unwrap();
1429
1430 let temp_file = NamedTempFile::new().unwrap();
1431 package.write_file(temp_file.path()).unwrap();
1432
1433 let pkg = RpmParser::extract_first_package(temp_file.path());
1434
1435 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("LGPLv2"));
1436 assert_eq!(pkg.declared_license_expression.as_deref(), Some("lgpl-2.0"));
1437 assert_eq!(
1438 pkg.declared_license_expression_spdx.as_deref(),
1439 Some("LGPL-2.0-only")
1440 );
1441 assert_eq!(pkg.license_detections.len(), 1);
1442 assert_eq!(
1443 pkg.license_detections[0].license_expression_spdx,
1444 "LGPL-2.0-only"
1445 );
1446 assert_eq!(
1447 pkg.license_detections[0].matches[0].matched_text.as_deref(),
1448 Some("LGPLv2")
1449 );
1450 }
1451
1452 #[test]
1453 fn test_rpm_archive_normalizes_public_domain_declared_license_expression() {
1454 let package = rpm::PackageBuilder::new(
1455 "demo-public-domain",
1456 "1.0.0",
1457 "public domain",
1458 "noarch",
1459 "RPM public domain normalization fixture",
1460 )
1461 .release("1")
1462 .build()
1463 .unwrap();
1464
1465 let temp_file = NamedTempFile::new().unwrap();
1466 package.write_file(temp_file.path()).unwrap();
1467
1468 let pkg = RpmParser::extract_first_package(temp_file.path());
1469
1470 assert_eq!(
1471 pkg.extracted_license_statement.as_deref(),
1472 Some("public domain")
1473 );
1474 assert_eq!(
1475 pkg.declared_license_expression.as_deref(),
1476 Some("licenseref-scancode-public-domain")
1477 );
1478 assert_eq!(
1479 pkg.declared_license_expression_spdx.as_deref(),
1480 Some("LicenseRef-scancode-public-domain")
1481 );
1482 assert_eq!(pkg.license_detections.len(), 1);
1483 }
1484
1485 #[test]
1486 fn test_normalize_rpm_declared_license_rewrites_compound_aliases() {
1487 let normalized = normalize_rpm_declared_license("BSD and GPLv2+")
1488 .expect("compound RPM license should normalize");
1489
1490 assert_eq!(
1491 normalized.declared_license_expression_spdx,
1492 "BSD-3-Clause AND GPL-2.0-or-later"
1493 );
1494 }
1495}