Skip to main content

provenant/parsers/
rpm_specfile.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for RPM .spec files.
5//!
6//! Extracts package metadata from RPM specfiles, which define how RPM packages
7//! are built. This is an intentional improvement over parity - the Python version is
8//! a complete stub with "TODO: implement me!!" comments.
9//!
10//! # Supported Formats
11//! - *.spec (RPM specfiles)
12//!
13//! # Key Features
14//! - Preamble tag extraction (Name, Version, Release, Summary, License, etc.)
15//! - Dependency extraction (BuildRequires, Requires, Provides)
16//! - %description section parsing
17//! - Basic macro expansion (%{name}, %{version}, %{release})
18//! - %define and %global macro definitions
19//! - Conditional macro handling (%{?dist})
20//! - Multi-line dependency lists (comma-separated)
21//! - Scoped Requires (Requires(post), Requires(preun), etc.)
22//!
23//! # Implementation Notes
24//! - Parses only the preamble (before %prep, %build, etc. sections)
25//! - Tags are case-insensitive per RPM spec format
26//! - Simple macro expansion for common patterns
27//! - BuildRequires dependencies have is_runtime=false, scope="build"
28//! - Runtime Requires dependencies have is_runtime=true, scope="runtime"
29//! - datasource_id is "rpm_specfile"
30
31use std::collections::HashMap;
32use std::path::Path;
33use std::sync::LazyLock;
34
35use crate::parser_warn as warn;
36use packageurl::PackageUrl;
37use regex::Regex;
38
39use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
40use crate::parsers::utils::{
41    CappedIterExt, MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string,
42    split_name_email, truncate_field,
43};
44
45use super::PackageParser;
46
47static RE_CONDITIONAL_MACRO: LazyLock<Regex> = LazyLock::new(|| {
48    Regex::new(r"%\{\?[^}]+\}").expect("valid regex: %{?...} pattern is a compile-time constant")
49});
50
51const PACKAGE_TYPE: PackageType = PackageType::Rpm;
52
53/// Parser for RPM specfiles
54pub struct RpmSpecfileParser;
55
56impl PackageParser for RpmSpecfileParser {
57    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
58
59    fn is_match(path: &Path) -> bool {
60        path.extension()
61            .and_then(|e| e.to_str())
62            .is_some_and(|ext| ext.eq_ignore_ascii_case("spec"))
63    }
64
65    fn extract_packages(path: &Path) -> Vec<PackageData> {
66        let content = match read_file_to_string(path, None) {
67            Ok(c) => c,
68            Err(e) => {
69                warn!("Failed to read RPM specfile {:?}: {}", path, e);
70                return vec![PackageData {
71                    package_type: Some(PACKAGE_TYPE),
72                    datasource_id: Some(DatasourceId::RpmSpecfile),
73                    ..Default::default()
74                }];
75            }
76        };
77
78        vec![parse_specfile(&content)]
79    }
80
81    fn metadata() -> Vec<super::metadata::ParserMetadata> {
82        vec![super::metadata::ParserMetadata {
83            description: "RPM specfile",
84            file_patterns: &["**/*.spec"],
85            package_type: "rpm",
86            primary_language: "",
87            documentation_url: Some(
88                "https://rpm-software-management.github.io/rpm/manual/spec.html",
89            ),
90        }]
91    }
92}
93
94fn parse_specfile(content: &str) -> PackageData {
95    let mut tags: HashMap<String, String> = HashMap::new();
96    let mut macros: HashMap<String, String> = HashMap::new();
97    let mut build_requires: Vec<String> = Vec::new();
98    let mut requires: Vec<(String, Option<String>)> = Vec::new(); // (requirement, scope)
99    let mut provides: Vec<String> = Vec::new();
100    let mut description: Option<String> = None;
101
102    let lines: Vec<&str> = content.lines().collect();
103    let mut i = 0;
104    let mut iterations: usize = 0;
105
106    while i < lines.len() {
107        iterations += 1;
108        if iterations > MAX_ITERATION_COUNT {
109            warn!(
110                "RPM specfile preamble iteration limit ({}) exceeded",
111                MAX_ITERATION_COUNT
112            );
113            break;
114        }
115        let line = lines[i].trim();
116
117        // Stop at first section marker (%, but not %define/%global)
118        if line.starts_with('%') && !line.starts_with("%define") && !line.starts_with("%global") {
119            if is_conditional_preamble_directive(line) {
120                i += 1;
121                continue;
122            }
123            break;
124        }
125
126        // Skip empty lines and comments
127        if line.is_empty() || line.starts_with('#') {
128            i += 1;
129            continue;
130        }
131
132        // Parse %define and %global macros
133        if let Some(stripped) = line
134            .strip_prefix("%define")
135            .or(line.strip_prefix("%global"))
136        {
137            let parts: Vec<&str> = stripped.trim().splitn(2, char::is_whitespace).collect();
138            if parts.len() == 2 {
139                macros.insert(
140                    parts[0].to_string(),
141                    truncate_field(parts[1].trim().to_string()),
142                );
143            }
144            i += 1;
145            continue;
146        }
147
148        // Parse Tag: Value lines
149        if let Some(colon_pos) = line.find(':') {
150            let tag = line[..colon_pos].trim().to_lowercase();
151            let value = line[colon_pos + 1..].trim().to_string();
152
153            match tag.as_str() {
154                "buildrequires" => {
155                    for dep in value.split(',').capped("rpm specfile BuildRequires") {
156                        let dep = dep.trim();
157                        if !dep.is_empty() {
158                            build_requires.push(dep.to_string());
159                        }
160                    }
161                }
162                t if t.starts_with("requires") => {
163                    // Parse Requires, Requires(post), Requires(preun), etc.
164                    let scope = if let Some(start) = t.find('(') {
165                        if let Some(end) = t.find(')') {
166                            Some(t[start + 1..end].to_string())
167                        } else {
168                            Some("runtime".to_string())
169                        }
170                    } else {
171                        Some("runtime".to_string())
172                    };
173
174                    for dep in value.split(',').capped("rpm specfile Requires") {
175                        let dep = dep.trim();
176                        if !dep.is_empty() {
177                            requires.push((dep.to_string(), scope.clone()));
178                        }
179                    }
180                }
181                "provides" => {
182                    for prov in value.split(',').capped("rpm specfile Provides") {
183                        let prov = prov.trim();
184                        if !prov.is_empty() {
185                            provides.push(prov.to_string());
186                        }
187                    }
188                }
189                _ => {
190                    tags.insert(tag, value);
191                }
192            }
193        }
194
195        i += 1;
196    }
197
198    // Now parse %description section if present
199    let mut desc_iterations: usize = 0;
200    while i < lines.len() {
201        desc_iterations += 1;
202        if desc_iterations > MAX_ITERATION_COUNT {
203            warn!(
204                "RPM specfile description search iteration limit ({}) exceeded",
205                MAX_ITERATION_COUNT
206            );
207            break;
208        }
209        let line = lines[i].trim();
210
211        if line.starts_with("%description") {
212            i += 1;
213            let mut desc_lines = Vec::new();
214
215            while i < lines.len() {
216                desc_iterations += 1;
217                if desc_iterations > MAX_ITERATION_COUNT {
218                    warn!(
219                        "RPM specfile description iteration limit ({}) exceeded",
220                        MAX_ITERATION_COUNT
221                    );
222                    break;
223                }
224                let desc_line = lines[i];
225                let trimmed = desc_line.trim();
226
227                // Stop at next section
228                if trimmed.starts_with('%') {
229                    break;
230                }
231
232                // Don't include empty lines at start
233                if !desc_lines.is_empty() || !trimmed.is_empty() {
234                    desc_lines.push(desc_line);
235                }
236
237                i += 1;
238            }
239
240            // Trim trailing empty lines
241            while desc_lines.last().is_some_and(|l| l.trim().is_empty()) {
242                desc_lines.pop();
243            }
244
245            if !desc_lines.is_empty() {
246                description = Some(desc_lines.join("\n"));
247            }
248
249            break;
250        }
251
252        i += 1;
253    }
254
255    // Extract basic metadata from tags
256    let name = tags.get("name").cloned();
257    let version = tags.get("version").cloned();
258    let release = tags.get("release").cloned();
259
260    // Store name and version in macros for expansion
261    if let Some(ref n) = name {
262        macros.insert("name".to_string(), n.clone());
263    }
264    if let Some(ref v) = version {
265        macros.insert("version".to_string(), v.clone());
266    }
267    if let Some(ref r) = release {
268        macros.insert("release".to_string(), r.clone());
269    }
270
271    // Expand macros in all tag values
272    let mut expanded_tags: HashMap<String, String> = HashMap::new();
273    for (tag, value) in tags.iter() {
274        expanded_tags.insert(tag.clone(), truncate_field(expand_macros(value, &macros)));
275    }
276
277    // Get expanded values
278    let name = expanded_tags.get("name").cloned();
279    let version = expanded_tags.get("version").cloned();
280    let release = expanded_tags.get("release").cloned();
281    let summary = expanded_tags.get("summary").cloned();
282    let license = expanded_tags.get("license").cloned();
283    let url = expanded_tags.get("url").cloned();
284    let group = expanded_tags.get("group").cloned();
285    let epoch = expanded_tags.get("epoch").cloned();
286    let packager = expanded_tags.get("packager").cloned();
287
288    let download_url = expanded_tags
289        .get("source")
290        .or_else(|| expanded_tags.get("source0"))
291        .cloned()
292        .map(truncate_field);
293
294    // Create parties
295    let mut parties = Vec::new();
296    if let Some(pkg) = packager {
297        let (name_opt, email_opt) = split_name_email(&pkg);
298        parties.push(Party {
299            r#type: None,
300            role: Some("packager".to_string()),
301            name: name_opt,
302            email: email_opt,
303            url: None,
304            organization: None,
305            organization_url: None,
306            timezone: None,
307        });
308    }
309
310    // Create dependencies
311    let mut dependencies = Vec::new();
312
313    let build_requires_limit =
314        capped_iteration_limit(build_requires.len(), "rpm specfile build dependencies");
315    for dep_str in build_requires.into_iter().take(build_requires_limit) {
316        let dep_str = truncate_field(expand_macros(&dep_str, &macros));
317        let dep_name = extract_dep_name(&dep_str);
318        let purl = build_rpm_purl(&dep_name, None).map(truncate_field);
319
320        dependencies.push(Dependency {
321            purl,
322            extracted_requirement: Some(dep_str),
323            scope: Some("build".to_string()),
324            is_runtime: Some(false),
325            is_optional: Some(false),
326            is_direct: Some(true),
327            is_pinned: None,
328            resolved_package: None,
329            extra_data: None,
330        });
331    }
332
333    let requires_limit =
334        capped_iteration_limit(requires.len(), "rpm specfile runtime dependencies");
335    for (dep_str, scope) in requires.into_iter().take(requires_limit) {
336        let dep_str = truncate_field(expand_macros(&dep_str, &macros));
337        let dep_name = extract_dep_name(&dep_str);
338        let purl = build_rpm_purl(&dep_name, None).map(truncate_field);
339
340        dependencies.push(Dependency {
341            purl,
342            extracted_requirement: Some(dep_str),
343            scope,
344            is_runtime: Some(true),
345            is_optional: Some(false),
346            is_direct: Some(true),
347            is_pinned: None,
348            resolved_package: None,
349            extra_data: None,
350        });
351    }
352
353    // Build PURL
354    let purl = name
355        .as_ref()
356        .and_then(|n| build_rpm_purl(n, version.as_deref()))
357        .map(truncate_field);
358
359    // Build extra_data for non-standard fields
360    let mut extra_data = HashMap::new();
361    if let Some(r) = release {
362        extra_data.insert("release".to_string(), serde_json::Value::String(r));
363    }
364    if let Some(e) = epoch {
365        extra_data.insert("epoch".to_string(), serde_json::Value::String(e));
366    }
367    if let Some(g) = group {
368        extra_data.insert("group".to_string(), serde_json::Value::String(g));
369    }
370    if !provides.is_empty() {
371        let provides_limit = capped_iteration_limit(provides.len(), "rpm specfile provides");
372        let provides_json: Vec<serde_json::Value> = provides
373            .into_iter()
374            .take(provides_limit)
375            .map(|prov| serde_json::Value::String(truncate_field(expand_macros(&prov, &macros))))
376            .collect();
377        extra_data.insert(
378            "provides".to_string(),
379            serde_json::Value::Array(provides_json),
380        );
381    }
382
383    let extra_data_opt = if extra_data.is_empty() {
384        None
385    } else {
386        Some(extra_data)
387    };
388
389    // Use %description if available, otherwise use Summary
390    let description_text = description.map(truncate_field).or(summary);
391
392    PackageData {
393        datasource_id: Some(DatasourceId::RpmSpecfile),
394        package_type: Some(PACKAGE_TYPE),
395        namespace: None, // RPM namespace is optional
396        name,
397        version,
398        description: description_text,
399        homepage_url: url,
400        download_url,
401        extracted_license_statement: license,
402        parties,
403        dependencies,
404        purl,
405        extra_data: extra_data_opt,
406        ..Default::default()
407    }
408}
409
410fn is_conditional_preamble_directive(line: &str) -> bool {
411    [
412        "%if", "%ifarch", "%ifnarch", "%ifos", "%ifnos", "%elif", "%else", "%endif",
413    ]
414    .iter()
415    .any(|directive| line.starts_with(directive))
416}
417
418/// Expands simple macros in a string (%{name}, %{version}, %{release}, %{?dist})
419fn expand_macros(s: &str, macros: &HashMap<String, String>) -> String {
420    let mut result = s.to_string();
421
422    result = RE_CONDITIONAL_MACRO.replace_all(&result, "").to_string();
423
424    // Expand simple macros %{macro}
425    for (key, value) in macros {
426        let pattern = format!("%{{{}}}", key);
427        result = result.replace(&pattern, value);
428    }
429
430    result = RE_CONDITIONAL_MACRO.replace_all(&result, "").to_string();
431
432    result
433}
434
435/// Extracts the package name from a dependency string (removes version constraints)
436fn extract_dep_name(dep: &str) -> String {
437    let parts: Vec<&str> = dep.split(&['>', '<', '='][..]).map(|s| s.trim()).collect();
438
439    truncate_field(parts[0].to_string())
440}
441
442/// Builds a package URL for RPM packages.
443/// Returns `None` for file-path dependencies (e.g. `/bin/bash`) since they
444/// are not valid purl names and would produce broken `%2F`-encoded results.
445fn build_rpm_purl(name: &str, version: Option<&str>) -> Option<String> {
446    if name.starts_with('/') {
447        return None;
448    }
449
450    let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
451
452    if let Some(ver) = version {
453        purl.with_version(ver).ok()?;
454    }
455
456    Some(purl.to_string())
457}