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
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
552/// Parser for RPM package archives
553pub 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    // The rpm epoch is emitted as an `?epoch=` qualifier, not folded into the
606    // version, matching the rpm database parser.
607    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    // `source` is not a spec-defined rpm qualifier; preserve the signal here.
732    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    // The rpm epoch is a qualifier, not part of the version.
1070    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        // `source` is not a spec rpm qualifier; the signal lives in extra_data
1313        // and must not appear on the PURL.
1314        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}