1use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9use serde_json::Value as JsonValue;
10
11use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
12use crate::parsers::utils::{
13 CappedIterExt, capped_iteration_limit, read_file_to_string, split_name_email, truncate_field,
14};
15
16use super::PackageParser;
17use super::metadata::ParserMetadata;
18
19const PACKAGE_TYPE: PackageType = PackageType::Alpm;
20const PACKAGE_NAMESPACE: &str = "arch";
21
22pub struct ArchSrcinfoParser;
23pub struct ArchPkginfoParser;
24
25impl PackageParser for ArchSrcinfoParser {
26 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
27
28 fn metadata() -> Vec<ParserMetadata> {
29 vec![ParserMetadata {
30 description: "Arch Linux package metadata (.SRCINFO, .AURINFO, .PKGINFO)",
31 file_patterns: &["**/.SRCINFO", "**/.AURINFO", "**/.PKGINFO"],
32 package_type: "alpm",
33 primary_language: "",
34 documentation_url: Some("https://wiki.archlinux.org/title/.SRCINFO"),
35 }]
36 }
37
38 fn is_match(path: &Path) -> bool {
39 path.file_name()
40 .and_then(|name| name.to_str())
41 .is_some_and(|name| matches!(name, ".SRCINFO" | ".AURINFO"))
42 }
43
44 fn extract_packages(path: &Path) -> Vec<PackageData> {
45 let content = match read_file_to_string(path, None) {
46 Ok(content) => content,
47 Err(e) => {
48 warn!("Failed to read Arch source metadata {:?}: {}", path, e);
49 return vec![default_package_data(srcinfo_datasource_id(path))];
50 }
51 };
52
53 parse_srcinfo_like(&content, srcinfo_datasource_id(path))
54 }
55}
56
57impl PackageParser for ArchPkginfoParser {
58 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
59
60 fn is_match(path: &Path) -> bool {
61 path.file_name().and_then(|name| name.to_str()) == Some(".PKGINFO")
62 }
63
64 fn extract_packages(path: &Path) -> Vec<PackageData> {
65 let content = match read_file_to_string(path, None) {
66 Ok(content) => content,
67 Err(e) => {
68 warn!("Failed to read Arch .PKGINFO {:?}: {}", path, e);
69 return vec![default_package_data(DatasourceId::ArchPkginfo)];
70 }
71 };
72
73 vec![parse_pkginfo(&content)]
74 }
75}
76
77fn default_package_data(datasource_id: DatasourceId) -> PackageData {
78 PackageData {
79 package_type: Some(PACKAGE_TYPE),
80 namespace: Some(PACKAGE_NAMESPACE.to_string()),
81 datasource_id: Some(datasource_id),
82 ..Default::default()
83 }
84}
85
86fn srcinfo_datasource_id(path: &Path) -> DatasourceId {
87 match path.file_name().and_then(|name| name.to_str()) {
88 Some(".AURINFO") => DatasourceId::ArchAurinfo,
89 _ => DatasourceId::ArchSrcinfo,
90 }
91}
92
93type MultiMap = HashMap<String, Vec<String>>;
94
95fn parse_key_value_lines(content: &str) -> MultiMap {
96 let mut fields: MultiMap = HashMap::new();
97
98 for line in content.lines().capped("arch key=value lines") {
99 let line = line.trim();
100 if line.is_empty() || line.starts_with('#') {
101 continue;
102 }
103
104 if let Some((key, value)) = line.split_once('=') {
105 let key = key.trim();
106 let value = value.trim();
107 if !key.is_empty() {
108 fields
109 .entry(key.to_string())
110 .or_default()
111 .push(truncate_field(value.to_string()));
112 }
113 }
114 }
115
116 fields
117}
118
119fn parse_srcinfo_like(content: &str, datasource_id: DatasourceId) -> Vec<PackageData> {
120 let mut pkgbase: MultiMap = HashMap::new();
121 let mut packages: Vec<MultiMap> = Vec::new();
122 let mut current_is_pkgbase = true;
123
124 for line in content.lines().capped("arch .SRCINFO-like lines") {
125 let line = line.trim();
126 if line.is_empty() || line.starts_with('#') {
127 continue;
128 }
129
130 let Some((key, value)) = line.split_once('=') else {
131 continue;
132 };
133
134 let key = key.trim();
135 let value = value.trim();
136
137 if key == "pkgbase" {
138 pkgbase
139 .entry(key.to_string())
140 .or_default()
141 .push(truncate_field(value.to_string()));
142 current_is_pkgbase = true;
143 continue;
144 }
145
146 if key == "pkgname" {
147 packages.push(HashMap::from([(
148 key.to_string(),
149 vec![truncate_field(value.to_string())],
150 )]));
151 current_is_pkgbase = false;
152 continue;
153 }
154
155 let target = if current_is_pkgbase {
156 &mut pkgbase
157 } else {
158 packages.last_mut().unwrap_or(&mut pkgbase)
159 };
160
161 target
162 .entry(key.to_string())
163 .or_default()
164 .push(truncate_field(value.to_string()));
165 }
166
167 if packages.is_empty() {
168 packages.push(HashMap::new());
169 }
170
171 let results: Vec<_> = packages
172 .into_iter()
173 .filter_map(|package_section| {
174 let merged = merge_srcinfo_sections(&pkgbase, &package_section);
175 let pkg = build_package_from_arch_metadata(&merged, datasource_id, true);
176 pkg.name.is_some().then_some(pkg)
177 })
178 .collect();
179
180 if results.is_empty() {
181 vec![default_package_data(datasource_id)]
182 } else {
183 results
184 }
185}
186
187fn merge_srcinfo_sections(pkgbase: &MultiMap, package: &MultiMap) -> MultiMap {
188 let mut merged = pkgbase.clone();
189
190 for (key, values) in package {
191 if should_append_srcinfo_values(key) {
192 merged
193 .entry(key.clone())
194 .or_default()
195 .extend(values.clone());
196 } else {
197 merged.insert(key.clone(), values.clone());
198 }
199 }
200
201 if !merged.contains_key("pkgname")
202 && let Some(pkgbase_name) = pkgbase.get("pkgbase").and_then(|vals| vals.first())
203 {
204 merged.insert("pkgname".to_string(), vec![pkgbase_name.clone()]);
205 }
206
207 merged
208}
209
210fn should_append_srcinfo_values(key: &str) -> bool {
211 matches!(
212 key,
213 "arch"
214 | "groups"
215 | "license"
216 | "noextract"
217 | "options"
218 | "backup"
219 | "validpgpkeys"
220 | "source"
221 | "depends"
222 | "makedepends"
223 | "checkdepends"
224 | "optdepends"
225 | "provides"
226 | "conflicts"
227 | "replaces"
228 | "md5sums"
229 | "sha1sums"
230 | "sha224sums"
231 | "sha256sums"
232 | "sha384sums"
233 | "sha512sums"
234 | "b2sums"
235 | "cksums"
236 ) || is_arch_variant_key(key)
237}
238
239fn is_arch_variant_key(key: &str) -> bool {
240 arch_variant_base(key).is_some()
241}
242
243fn arch_variant_base(key: &str) -> Option<&'static str> {
244 [
245 "source",
246 "depends",
247 "makedepends",
248 "checkdepends",
249 "optdepends",
250 "provides",
251 "conflicts",
252 "replaces",
253 "md5sums",
254 "sha1sums",
255 "sha224sums",
256 "sha256sums",
257 "sha384sums",
258 "sha512sums",
259 "b2sums",
260 "cksums",
261 ]
262 .into_iter()
263 .find(|base| {
264 key.strip_prefix(base)
265 .and_then(|rest| rest.strip_prefix('_'))
266 .is_some_and(|arch| !arch.is_empty())
267 })
268}
269
270fn parse_pkginfo(content: &str) -> PackageData {
271 let fields = parse_key_value_lines(content);
272 build_package_from_arch_metadata(&fields, DatasourceId::ArchPkginfo, false)
273}
274
275fn build_package_from_arch_metadata(
276 fields: &MultiMap,
277 datasource_id: DatasourceId,
278 is_srcinfo_like: bool,
279) -> PackageData {
280 let name = get_first(fields, "pkgname");
281 let pkgbase = get_first(fields, "pkgbase").or_else(|| name.clone());
282 let version = if is_srcinfo_like {
283 build_srcinfo_version(fields)
284 } else {
285 get_first(fields, "pkgver")
286 };
287 let description = get_first(fields, "pkgdesc");
288 let homepage_url = get_first(fields, "url");
289 let extracted_license_statement = join_values(fields.get("license"));
290 let arch_values = get_all(fields, "arch");
291 let purl_arch = (arch_values.len() == 1).then(|| arch_values[0].as_str());
292
293 let mut package = default_package_data(datasource_id);
294 package.name = name.map(truncate_field);
295 package.version = version.map(truncate_field);
296 package.description = description.map(truncate_field);
297 package.homepage_url = homepage_url.map(truncate_field);
298 package.extracted_license_statement = extracted_license_statement.map(truncate_field);
299 package.primary_language = None;
300 package.purl = package
301 .name
302 .as_deref()
303 .and_then(|name| build_alpm_purl(name, package.version.as_deref(), purl_arch));
304 package.source_packages = pkgbase
305 .and_then(|base| build_alpm_purl(&base, package.version.as_deref(), purl_arch))
306 .into_iter()
307 .collect();
308
309 if !is_srcinfo_like {
310 if let Some(packager) = get_first(fields, "packager") {
311 let (packager_name, packager_email) = split_name_email(&packager);
312 package.parties.push(Party {
313 r#type: Some(PartyType::Person),
314 role: Some("packager".to_string()),
315 name: packager_name.map(truncate_field),
316 email: packager_email.map(truncate_field),
317 url: None,
318 organization: None,
319 organization_url: None,
320 timezone: None,
321 });
322 }
323 package.size = get_first(fields, "size").and_then(|size| size.parse::<u64>().ok());
324 }
325
326 package.dependencies = build_dependencies(fields);
327 package.extra_data = build_extra_data(fields, is_srcinfo_like, purl_arch);
328 package
329}
330
331fn build_srcinfo_version(fields: &MultiMap) -> Option<String> {
332 let pkgver = get_first(fields, "pkgver")?;
333 let pkgrel = get_first(fields, "pkgrel");
334 let epoch = get_first(fields, "epoch");
335
336 let mut version = match pkgrel {
337 Some(pkgrel) => format!("{}-{}", pkgver, pkgrel),
338 None => pkgver,
339 };
340
341 if let Some(epoch) = epoch
342 && epoch != "0"
343 {
344 version = format!("{}:{}", epoch, version);
345 }
346
347 Some(version)
348}
349
350fn build_alpm_purl(name: &str, version: Option<&str>, arch: Option<&str>) -> Option<String> {
351 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
352 purl.with_namespace(PACKAGE_NAMESPACE).ok()?;
353
354 if let Some(version) = version {
355 purl.with_version(version).ok()?;
356 }
357
358 if let Some(arch) = arch {
359 purl.add_qualifier("arch", arch).ok()?;
360 }
361
362 Some(purl.to_string())
363}
364
365fn build_dependencies(fields: &MultiMap) -> Vec<Dependency> {
366 let mut dependencies = Vec::new();
367 let mut keys: Vec<_> = fields.keys().cloned().collect();
368 keys.sort();
369
370 let limit = capped_iteration_limit(keys.len(), "arch dependency keys");
371 for key in keys.iter().take(limit) {
372 let Some((scope, is_runtime, is_optional)) = dependency_semantics(key) else {
373 continue;
374 };
375
376 for value in get_all(fields, key) {
377 if let Some(dep_name) = extract_arch_dependency_name(&value) {
378 dependencies.push(Dependency {
379 purl: build_alpm_purl(&dep_name, None, None),
380 extracted_requirement: Some(truncate_field(value.clone())),
381 scope: Some(scope.to_string()),
382 is_runtime: Some(is_runtime),
383 is_optional: Some(is_optional),
384 is_pinned: Some(false),
385 is_direct: Some(true),
386 resolved_package: None,
387 extra_data: None,
388 });
389 }
390 }
391 }
392
393 dependencies
394}
395
396fn dependency_semantics(key: &str) -> Option<(&str, bool, bool)> {
397 let base = key;
398 let normalized = arch_variant_base(key).unwrap_or(key);
399
400 match normalized {
401 "depends" | "depend" => Some((base, true, false)),
402 "makedepends" | "makedepend" => Some((base, false, false)),
403 "checkdepends" | "checkdepend" => Some((base, false, false)),
404 "optdepends" | "optdepend" => Some((base, true, true)),
405 _ => None,
406 }
407}
408
409fn extract_arch_dependency_name(value: &str) -> Option<String> {
410 let dep = value.split(':').next()?.trim();
411 let end = dep.find(['<', '>', '=']).unwrap_or(dep.len());
412 let name = dep[..end].trim();
413 (!name.is_empty()).then(|| truncate_field(name.to_string()))
414}
415
416fn build_extra_data(
417 fields: &MultiMap,
418 is_srcinfo_like: bool,
419 purl_arch: Option<&str>,
420) -> Option<HashMap<String, JsonValue>> {
421 let consumed: HashSet<&str> = HashSet::from([
422 "pkgbase", "pkgname", "pkgver", "pkgrel", "epoch", "pkgdesc", "url", "license", "packager",
423 "size",
424 ]);
425
426 let mut extra = HashMap::new();
427
428 let mut field_keys: Vec<&String> = fields.keys().collect();
431 field_keys.sort();
432 let limit = capped_iteration_limit(field_keys.len(), "arch extra fields");
433 for key in field_keys.into_iter().take(limit) {
434 let Some(values) = fields.get(key) else {
435 continue;
436 };
437 if consumed.contains(key.as_str()) {
438 continue;
439 }
440
441 let value = if should_force_array_extra_value(key) {
442 JsonValue::Array(
443 values
444 .iter()
445 .cloned()
446 .map(|v| JsonValue::String(truncate_field(v)))
447 .collect(),
448 )
449 } else if values.len() == 1 {
450 if key == "builddate" {
451 values[0]
452 .parse::<u64>()
453 .map(JsonValue::from)
454 .unwrap_or_else(|_| JsonValue::String(truncate_field(values[0].clone())))
455 } else {
456 JsonValue::String(truncate_field(values[0].clone()))
457 }
458 } else {
459 JsonValue::Array(
460 values
461 .iter()
462 .cloned()
463 .map(|v| JsonValue::String(truncate_field(v)))
464 .collect(),
465 )
466 };
467 extra.insert(key.clone(), value);
468 }
469
470 if is_srcinfo_like && !fields.contains_key("pkgbase") && !fields.contains_key("pkgname") {
471 return None;
472 }
473
474 if !is_srcinfo_like
475 && purl_arch.is_some()
476 && !extra.contains_key("arch")
477 && let Some(arch) = purl_arch
478 {
479 extra.insert(
480 "arch".to_string(),
481 JsonValue::String(truncate_field(arch.to_string())),
482 );
483 }
484
485 (!extra.is_empty()).then_some(extra)
486}
487
488fn get_first(fields: &MultiMap, key: &str) -> Option<String> {
489 fields.get(key).and_then(|values| values.first()).cloned()
490}
491
492fn get_all(fields: &MultiMap, key: &str) -> Vec<String> {
493 fields.get(key).cloned().unwrap_or_default()
494}
495
496fn join_values(values: Option<&Vec<String>>) -> Option<String> {
497 let values = values?;
498 if values.is_empty() {
499 None
500 } else {
501 Some(values.join(" AND "))
502 }
503}
504
505fn should_force_array_extra_value(key: &str) -> bool {
506 matches!(
507 key,
508 "provides"
509 | "conflict"
510 | "conflicts"
511 | "replace"
512 | "replaces"
513 | "source"
514 | "arch"
515 | "license"
516 | "groups"
517 | "options"
518 | "backup"
519 | "validpgpkeys"
520 | "md5sums"
521 | "sha1sums"
522 | "sha224sums"
523 | "sha256sums"
524 | "sha384sums"
525 | "sha512sums"
526 | "b2sums"
527 | "cksums"
528 ) || is_arch_variant_key(key)
529}