1use 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
53pub 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(); 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 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 if line.is_empty() || line.starts_with('#') {
128 i += 1;
129 continue;
130 }
131
132 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 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 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 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 if trimmed.starts_with('%') {
229 break;
230 }
231
232 if !desc_lines.is_empty() || !trimmed.is_empty() {
234 desc_lines.push(desc_line);
235 }
236
237 i += 1;
238 }
239
240 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 let name = tags.get("name").cloned();
257 let version = tags.get("version").cloned();
258 let release = tags.get("release").cloned();
259
260 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 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, ¯os)));
275 }
276
277 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 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 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, ¯os));
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, ¯os));
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 let purl = name
355 .as_ref()
356 .and_then(|n| build_rpm_purl(n, version.as_deref()))
357 .map(truncate_field);
358
359 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, ¯os))))
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 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, 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
418fn 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 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
435fn 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
442fn 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}