Skip to main content

provenant/parsers/
rpm_parser.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for RPM package archives.
5//!
6//! Extracts package metadata and dependencies from binary RPM package (.rpm) files
7//! by reading the embedded header metadata.
8//!
9//! # Supported Formats
10//! - *.rpm (binary RPM package archives)
11//!
12//! # Key Features
13//! - Metadata extraction from RPM headers (name, version, release, architecture)
14//! - Dependency extraction (requires, provides, obsoletes)
15//! - License and distribution information parsing
16//! - Package URL (purl) generation for installed packages
17//! - Graceful handling of malformed or corrupted RPM files
18//!
19//! # Implementation Notes
20//! - Uses `rpm` crate for low-level RPM format parsing
21//! - RPM architecture is captured as namespace in metadata
22//! - Direct dependency tracking (all requires are direct)
23//! - Error handling with `warn!()` logs on parse failures
24
25use 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    // RPM INT32 entries have data_type 4.
347    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    // The epoch is emitted as an `?epoch=` qualifier, consistent with the
412    // crate-parsed and database paths.
413    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    // `source` is not a spec-defined rpm qualifier, so the source-vs-binary
482    // signal is preserved here rather than on the PURL.
483    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
547/// Parser for RPM package archives
548pub 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    // The rpm epoch is emitted as an `?epoch=` qualifier, not folded into the
601    // version, matching the rpm database parser.
602    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    // `source` is not a spec-defined rpm qualifier; preserve the signal here.
727    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    // The rpm epoch is a qualifier, not part of the version.
1061    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        // `source` is not a spec rpm qualifier; the signal lives in extra_data
1304        // and must not appear on the PURL.
1305        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}