1use std::collections::HashMap;
22use std::path::Path;
23
24use crate::parser_warn as warn;
25use crate::utils::magic;
26
27use crate::models::{
28 DatasourceId, Dependency, FileReference, LicenseDetection, PackageData, PackageType, Party,
29 Sha1Digest,
30};
31use crate::parsers::utils::{read_file_to_string, split_name_email};
32
33use super::PackageParser;
34use super::license_normalization::{
35 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
36 build_declared_license_data_from_pair, combine_normalized_licenses,
37 empty_declared_license_data, normalize_declared_license_key,
38};
39
40const PACKAGE_TYPE: PackageType = PackageType::Alpine;
41
42fn default_package_data(datasource_id: DatasourceId) -> PackageData {
43 PackageData {
44 package_type: Some(PACKAGE_TYPE),
45 datasource_id: Some(datasource_id),
46 ..Default::default()
47 }
48}
49
50pub struct AlpineInstalledParser;
52
53pub struct AlpineApkbuildParser;
54
55impl PackageParser for AlpineInstalledParser {
56 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
57
58 fn is_match(path: &Path) -> bool {
59 path.to_str()
60 .map(|p| p.contains("/lib/apk/db/") && p.ends_with("installed"))
61 .unwrap_or(false)
62 }
63
64 fn extract_packages(path: &Path) -> Vec<PackageData> {
65 let content = match read_file_to_string(path) {
66 Ok(c) => c,
67 Err(e) => {
68 warn!("Failed to read Alpine installed db {:?}: {}", path, e);
69 return vec![default_package_data(DatasourceId::AlpineInstalledDb)];
70 }
71 };
72
73 parse_alpine_installed_db(&content)
74 }
75}
76
77impl PackageParser for AlpineApkbuildParser {
78 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
79
80 fn is_match(path: &Path) -> bool {
81 path.file_name().and_then(|n| n.to_str()) == Some("APKBUILD")
82 }
83
84 fn extract_packages(path: &Path) -> Vec<PackageData> {
85 let content = match read_file_to_string(path) {
86 Ok(c) => c,
87 Err(e) => {
88 warn!("Failed to read APKBUILD {:?}: {}", path, e);
89 return vec![default_package_data(DatasourceId::AlpineApkbuild)];
90 }
91 };
92
93 vec![parse_apkbuild(&content)]
94 }
95}
96
97fn parse_alpine_installed_db(content: &str) -> Vec<PackageData> {
98 let raw_paragraphs: Vec<&str> = content
99 .split("\n\n")
100 .filter(|p| !p.trim().is_empty())
101 .collect();
102
103 let mut all_packages = Vec::new();
104
105 for raw_text in &raw_paragraphs {
106 let headers = parse_alpine_headers(raw_text);
107 let pkg = parse_alpine_package_paragraph(&headers, raw_text);
108 if pkg.name.is_some() {
109 all_packages.push(pkg);
110 }
111 }
112
113 if all_packages.is_empty() {
114 return vec![default_package_data(DatasourceId::AlpineInstalledDb)];
115 }
116
117 all_packages
118}
119
120fn parse_alpine_headers(content: &str) -> HashMap<String, Vec<String>> {
126 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
127
128 for line in content.lines() {
129 if line.is_empty() {
130 continue;
131 }
132
133 if let Some((key, value)) = line.split_once(':') {
134 let key = key.trim();
135 let value = value.trim();
136 if !key.is_empty() && !value.is_empty() {
137 headers
138 .entry(key.to_string())
139 .or_default()
140 .push(value.to_string());
141 }
142 }
143 }
144
145 headers
146}
147
148fn get_first(headers: &HashMap<String, Vec<String>>, key: &str) -> Option<String> {
149 headers
150 .get(key)
151 .and_then(|values| values.first())
152 .map(|v| v.trim().to_string())
153}
154
155fn get_all(headers: &HashMap<String, Vec<String>>, key: &str) -> Vec<String> {
156 headers
157 .get(key)
158 .cloned()
159 .unwrap_or_default()
160 .into_iter()
161 .filter(|v| !v.trim().is_empty())
162 .collect()
163}
164
165fn parse_alpine_package_paragraph(
166 headers: &HashMap<String, Vec<String>>,
167 raw_text: &str,
168) -> PackageData {
169 let name = get_first(headers, "P");
170 let version = get_first(headers, "V");
171 let description = get_first(headers, "T");
172 let homepage_url = get_first(headers, "U");
173 let architecture = get_first(headers, "A");
174
175 let is_virtual = description
176 .as_ref()
177 .is_some_and(|d| d == "virtual meta package");
178
179 let namespace = Some("alpine".to_string());
180 let mut parties = Vec::new();
181
182 if let Some(maintainer) = get_first(headers, "m") {
183 let (name_opt, email_opt) = split_name_email(&maintainer);
184 parties.push(Party {
185 r#type: None,
186 role: Some("maintainer".to_string()),
187 name: name_opt,
188 email: email_opt,
189 url: None,
190 organization: None,
191 organization_url: None,
192 timezone: None,
193 });
194 }
195
196 let extracted_license_statement = get_first(headers, "L");
197 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
198 build_alpine_license_data(extracted_license_statement.as_deref());
199
200 let source_packages = if let Some(origin) = get_first(headers, "o") {
201 vec![format!("pkg:alpine/{}", origin)]
202 } else {
203 Vec::new()
204 };
205 let vcs_url = get_first(headers, "c")
206 .map(|commit| format!("git+https://git.alpinelinux.org/aports/commit/?id={commit}"));
207
208 let mut dependencies = Vec::new();
209 for dep in get_all(headers, "D") {
210 for dep_str in dep.split_whitespace() {
211 if dep_str.starts_with("so:") || dep_str.starts_with("cmd:") {
212 continue;
213 }
214
215 dependencies.push(Dependency {
216 purl: Some(format!("pkg:alpine/{}", dep_str)),
217 extracted_requirement: None,
218 scope: Some("install".to_string()),
219 is_runtime: Some(true),
220 is_optional: Some(false),
221 is_direct: Some(true),
222 resolved_package: None,
223 extra_data: None,
224 is_pinned: Some(false),
225 });
226 }
227 }
228
229 let mut extra_data = HashMap::new();
230
231 if is_virtual {
232 extra_data.insert("is_virtual".to_string(), true.into());
233 }
234
235 if let Some(checksum) = get_first(headers, "C") {
236 extra_data.insert("checksum".to_string(), checksum.into());
237 }
238
239 if let Some(size) = get_first(headers, "S") {
240 extra_data.insert("compressed_size".to_string(), size.into());
241 }
242
243 if let Some(installed_size) = get_first(headers, "I") {
244 extra_data.insert("installed_size".to_string(), installed_size.into());
245 }
246
247 if let Some(timestamp) = get_first(headers, "t") {
248 extra_data.insert("build_timestamp".to_string(), timestamp.into());
249 }
250
251 if let Some(commit) = get_first(headers, "c") {
252 extra_data.insert("git_commit".to_string(), commit.into());
253 }
254
255 let providers = extract_providers(raw_text);
256 if !providers.is_empty() {
257 let provider_list: Vec<serde_json::Value> =
258 providers.into_iter().map(|s| s.into()).collect();
259 extra_data.insert("providers".to_string(), provider_list.into());
260 }
261
262 let file_references = extract_file_references(raw_text);
263
264 PackageData {
265 datasource_id: Some(DatasourceId::AlpineInstalledDb),
266 package_type: Some(PACKAGE_TYPE),
267 namespace: namespace.clone(),
268 name: name.clone(),
269 version: version.clone(),
270 description,
271 homepage_url,
272 vcs_url,
273 parties,
274 declared_license_expression,
275 declared_license_expression_spdx,
276 license_detections,
277 extracted_license_statement,
278 source_packages,
279 dependencies,
280 file_references,
281 purl: name
282 .as_ref()
283 .and_then(|n| build_alpine_purl(n, version.as_deref(), architecture.as_deref())),
284 extra_data: if extra_data.is_empty() {
285 None
286 } else {
287 Some(extra_data)
288 },
289 ..Default::default()
290 }
291}
292
293fn parse_apkbuild(content: &str) -> PackageData {
294 let variables = parse_apkbuild_variables(content);
295
296 let name = variables.get("pkgname").cloned();
297 let version = match (variables.get("pkgver"), variables.get("pkgrel")) {
298 (Some(ver), Some(rel)) => Some(format!("{}-r{}", ver, rel)),
299 (Some(ver), None) => Some(ver.clone()),
300 _ => None,
301 };
302 let description = variables.get("pkgdesc").cloned();
303 let homepage_url = variables.get("url").cloned();
304 let extracted_license_statement = variables.get("license").cloned();
305 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
306 build_alpine_license_data(extracted_license_statement.as_deref());
307
308 let dependencies = parse_apkbuild_dependencies(&variables);
309
310 let mut extra_data = HashMap::new();
311 if let Some(source) = variables.get("source") {
312 let sources_value: Vec<serde_json::Value> = parse_apkbuild_sources(source)
313 .into_iter()
314 .map(|(file_name, url)| serde_json::json!({ "file_name": file_name, "url": url }))
315 .collect();
316 if !sources_value.is_empty() {
317 extra_data.insert(
318 "sources".to_string(),
319 serde_json::Value::Array(sources_value),
320 );
321 }
322 }
323 for (field, checksum_key) in [
324 ("sha512sums", "sha512"),
325 ("sha256sums", "sha256"),
326 ("md5sums", "md5"),
327 ] {
328 if let Some(checksums) = variables.get(field) {
329 let checksum_entries: Vec<serde_json::Value> = parse_apkbuild_checksums(checksums)
330 .into_iter()
331 .map(|(file_name, checksum)| serde_json::json!({ "file_name": file_name, checksum_key: checksum }))
332 .collect();
333 if !checksum_entries.is_empty() {
334 match extra_data.get_mut("checksums") {
335 Some(serde_json::Value::Array(existing)) => existing.extend(checksum_entries),
336 _ => {
337 extra_data.insert(
338 "checksums".to_string(),
339 serde_json::Value::Array(checksum_entries),
340 );
341 }
342 }
343 }
344 }
345 }
346
347 PackageData {
348 datasource_id: Some(DatasourceId::AlpineApkbuild),
349 package_type: Some(PACKAGE_TYPE),
350 namespace: None,
351 name: name.clone(),
352 version: version.clone(),
353 description,
354 homepage_url,
355 extracted_license_statement,
356 declared_license_expression,
357 declared_license_expression_spdx,
358 license_detections,
359 dependencies,
360 purl: name
361 .as_deref()
362 .and_then(|n| build_alpine_purl(n, version.as_deref(), None)),
363 extra_data: (!extra_data.is_empty()).then_some(extra_data),
364 ..default_package_data(DatasourceId::AlpineApkbuild)
365 }
366}
367
368fn parse_apkbuild_variables(content: &str) -> HashMap<String, String> {
369 let mut raw = HashMap::new();
370 let mut lines = content.lines().peekable();
371 let mut brace_depth = 0usize;
372
373 while let Some(line) = lines.next() {
374 let trimmed = line.trim();
375 if trimmed.is_empty() || trimmed.starts_with('#') {
376 continue;
377 }
378 if trimmed.ends_with("(){") || trimmed.ends_with("() {") {
379 brace_depth += 1;
380 continue;
381 }
382 if brace_depth > 0 {
383 brace_depth += trimmed.chars().filter(|c| *c == '{').count();
384 brace_depth = brace_depth.saturating_sub(trimmed.chars().filter(|c| *c == '}').count());
385 continue;
386 }
387 let Some((name, value)) = trimmed.split_once('=') else {
388 continue;
389 };
390 let mut value = value.trim().to_string();
391 if value.starts_with('"') && !value.ends_with('"') {
392 while let Some(next) = lines.peek() {
393 value.push('\n');
394 value.push_str(next);
395 let current = lines.next().unwrap();
396 if current.trim_end().ends_with('"') {
397 break;
398 }
399 }
400 }
401 raw.insert(name.trim().to_string(), value);
402 }
403
404 let mut resolved = HashMap::new();
405 for key in [
406 "pkgname",
407 "pkgver",
408 "pkgrel",
409 "pkgdesc",
410 "url",
411 "license",
412 "source",
413 "depends",
414 "depends_dev",
415 "makedepends",
416 "makedepends_build",
417 "makedepends_host",
418 "checkdepends",
419 "sha512sums",
420 "sha256sums",
421 "md5sums",
422 ] {
423 if let Some(value) = raw.get(key) {
424 resolved.insert(key.to_string(), resolve_apkbuild_value(value, &raw));
425 }
426 }
427 resolved
428}
429
430fn resolve_apkbuild_value(value: &str, variables: &HashMap<String, String>) -> String {
431 let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
432 for _ in 0..8 {
433 let previous = resolved.clone();
434 for (name, raw_value) in variables {
435 let raw_value = strip_wrapping_quotes(raw_value.trim());
436 let resolved_raw = resolve_apkbuild_value_no_recursion(raw_value, variables);
437 let value_resolved = strip_wrapping_quotes(&resolved_raw);
438 resolved = resolved.replace(
439 &format!("${{{name}//./-}}"),
440 &value_resolved.replace('.', "-"),
441 );
442 resolved = resolved.replace(
443 &format!("${{{name}//./_}}"),
444 &value_resolved.replace('.', "_"),
445 );
446 resolved = resolved.replace(
447 &format!("${{{name}::8}}"),
448 &value_resolved.chars().take(8).collect::<String>(),
449 );
450 resolved = resolved.replace(&format!("${{{name}}}"), value_resolved);
451 resolved = resolved.replace(&format!("${name}"), value_resolved);
452 }
453 if resolved == previous {
454 break;
455 }
456 }
457 resolved
458}
459
460fn resolve_apkbuild_value_no_recursion(value: &str, variables: &HashMap<String, String>) -> String {
461 let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
462 for (name, raw_value) in variables {
463 let raw_value = strip_wrapping_quotes(raw_value.trim());
464 resolved = resolved.replace(&format!("${{{name}//./-}}"), &raw_value.replace('.', "-"));
465 resolved = resolved.replace(&format!("${{{name}//./_}}"), &raw_value.replace('.', "_"));
466 resolved = resolved.replace(
467 &format!("${{{name}::8}}"),
468 &raw_value.chars().take(8).collect::<String>(),
469 );
470 resolved = resolved.replace(&format!("${{{name}}}"), raw_value);
471 resolved = resolved.replace(&format!("${name}"), raw_value);
472 }
473 resolved
474}
475
476fn strip_wrapping_quotes(value: &str) -> &str {
477 value
478 .strip_prefix('"')
479 .and_then(|v| v.strip_suffix('"'))
480 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
481 .unwrap_or(value)
482}
483
484fn parse_apkbuild_sources(value: &str) -> Vec<(Option<String>, Option<String>)> {
485 value
486 .split_whitespace()
487 .filter(|part| !part.is_empty())
488 .map(|part| {
489 if let Some((file_name, url)) = part.split_once("::") {
490 (Some(file_name.to_string()), Some(url.to_string()))
491 } else if part.contains("://") {
492 (None, Some(part.to_string()))
493 } else {
494 (Some(part.to_string()), None)
495 }
496 })
497 .collect()
498}
499
500fn parse_apkbuild_checksums(value: &str) -> Vec<(String, String)> {
501 value
502 .lines()
503 .flat_map(|line| line.split_whitespace())
504 .collect::<Vec<_>>()
505 .chunks(2)
506 .filter_map(|chunk| {
507 if chunk.len() == 2 {
508 Some((chunk[1].to_string(), chunk[0].to_string()))
509 } else {
510 None
511 }
512 })
513 .collect()
514}
515
516fn build_alpine_license_data(
517 extracted: Option<&str>,
518) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
519 let Some(extracted) = extracted.map(str::trim).filter(|s| !s.is_empty()) else {
520 return empty_declared_license_data();
521 };
522
523 if extracted == "custom:multiple" {
524 return build_declared_license_data_from_pair(
525 "unknown-license-reference",
526 "LicenseRef-provenant-unknown-license-reference",
527 DeclaredLicenseMatchMetadata::single_line(extracted),
528 );
529 }
530
531 let normalized_tokens = extracted
532 .split_whitespace()
533 .filter(|part| *part != "AND")
534 .map(normalize_alpine_license_token)
535 .collect::<Option<Vec<_>>>();
536
537 let Some(normalized_tokens) = normalized_tokens else {
538 return empty_declared_license_data();
539 };
540
541 let Some(combined) = combine_normalized_licenses(normalized_tokens, " AND ") else {
542 return empty_declared_license_data();
543 };
544
545 build_declared_license_data(
546 combined,
547 DeclaredLicenseMatchMetadata::single_line(extracted),
548 )
549}
550
551fn normalize_alpine_license_token(token: &str) -> Option<NormalizedDeclaredLicense> {
552 match token {
553 "ICU" => Some(NormalizedDeclaredLicense::new("x11", "ICU")),
554 "Unicode-TOU" => Some(NormalizedDeclaredLicense::new("unicode-tou", "Unicode-TOU")),
555 "Ruby" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
556 "BSD-2-Clause" => Some(NormalizedDeclaredLicense::new(
557 "bsd-simplified",
558 "BSD-2-Clause",
559 )),
560 "BSD-3-Clause" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
561 other => normalize_declared_license_key(other),
562 }
563}
564
565fn parse_apkbuild_dependencies(variables: &HashMap<String, String>) -> Vec<Dependency> {
566 let mut dependencies = Vec::new();
567
568 for (field, scope, is_runtime, is_optional) in [
569 ("depends", "depends", true, false),
570 ("depends_dev", "depends_dev", false, true),
571 ("makedepends", "makedepends", false, true),
572 ("makedepends_build", "makedepends_build", false, true),
573 ("makedepends_host", "makedepends_host", false, true),
574 ("checkdepends", "checkdepends", false, true),
575 ] {
576 let Some(value) = variables.get(field) else {
577 continue;
578 };
579
580 for dep_str in value.split_whitespace() {
581 let dep_str = dep_str.trim();
582 if dep_str.is_empty() {
583 continue;
584 }
585
586 let dep_name = dep_str
587 .split(['<', '>', '=', '!', '~'])
588 .next()
589 .unwrap_or(dep_str)
590 .trim();
591 if dep_name.is_empty() {
592 continue;
593 }
594
595 dependencies.push(Dependency {
596 purl: build_alpine_purl(dep_name, None, None),
597 extracted_requirement: Some(dep_str.to_string()),
598 scope: Some(scope.to_string()),
599 is_runtime: Some(is_runtime),
600 is_optional: Some(is_optional),
601 is_pinned: Some(dep_str.contains('=')),
602 is_direct: Some(true),
603 resolved_package: None,
604 extra_data: None,
605 });
606 }
607 }
608
609 dependencies
610}
611
612fn extract_file_references(raw_text: &str) -> Vec<FileReference> {
613 let mut file_references = Vec::new();
614 let mut current_dir = String::new();
615 let mut current_file: Option<FileReference> = None;
616
617 for line in raw_text.lines() {
618 if line.is_empty() {
619 continue;
620 }
621
622 if let Some((field_type, value)) = line.split_once(':') {
623 let value = value.trim();
624 match field_type {
625 "F" => {
626 if let Some(file) = current_file.take() {
627 file_references.push(file);
628 }
629 current_dir = value.to_string();
630 }
631 "R" => {
632 if let Some(file) = current_file.take() {
633 file_references.push(file);
634 }
635
636 let path = if current_dir.is_empty() {
637 value.to_string()
638 } else {
639 format!("{}/{}", current_dir, value)
640 };
641
642 current_file = Some(FileReference {
643 path,
644 size: None,
645 sha1: None,
646 md5: None,
647 sha256: None,
648 sha512: None,
649 extra_data: None,
650 });
651 }
652 "Z" => {
653 if let Some(ref mut file) = current_file
654 && value.starts_with("Q1")
655 {
656 use base64::Engine;
657 if let Ok(decoded) =
658 base64::engine::general_purpose::STANDARD.decode(&value[2..])
659 && let Ok(digest) = Sha1Digest::from_hex(
660 &decoded
661 .iter()
662 .map(|b| format!("{:02x}", b))
663 .collect::<String>(),
664 )
665 {
666 file.sha1 = Some(digest);
667 }
668 }
669 }
670 "a" => {
671 if let Some(ref mut file) = current_file {
672 let mut extra = HashMap::new();
673 extra.insert(
674 "attributes".to_string(),
675 serde_json::Value::String(value.to_string()),
676 );
677 file.extra_data = Some(extra);
678 }
679 }
680 _ => {}
681 }
682 }
683 }
684
685 if let Some(file) = current_file {
686 file_references.push(file);
687 }
688
689 file_references
690}
691
692fn extract_providers(raw_text: &str) -> Vec<String> {
693 let mut providers = Vec::new();
694
695 for line in raw_text.lines() {
696 if line.is_empty() {
697 continue;
698 }
699
700 if let Some(value) = line.strip_prefix("p:") {
701 providers.extend(value.split_whitespace().map(|s| s.to_string()));
702 }
703 }
704
705 providers
706}
707
708fn build_alpine_purl(
709 name: &str,
710 version: Option<&str>,
711 architecture: Option<&str>,
712) -> Option<String> {
713 use packageurl::PackageUrl;
714
715 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
716
717 if let Some(ver) = version {
718 purl.with_version(ver).ok()?;
719 }
720
721 if let Some(arch) = architecture {
722 purl.add_qualifier("arch", arch).ok()?;
723 }
724
725 Some(purl.to_string())
726}
727
728pub struct AlpineApkParser;
730
731impl PackageParser for AlpineApkParser {
732 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
733
734 fn is_match(path: &Path) -> bool {
735 path.extension().and_then(|e| e.to_str()) == Some("apk")
736 && magic::is_gzip(path)
737 && !magic::is_zip(path)
738 && apk_contains_pkginfo(path)
739 }
740
741 fn extract_packages(path: &Path) -> Vec<PackageData> {
742 vec![match extract_apk_archive(path) {
743 Ok(data) => data,
744 Err(e) => {
745 warn!("Failed to extract .apk archive {:?}: {}", path, e);
746 PackageData {
747 package_type: Some(PACKAGE_TYPE),
748 datasource_id: Some(DatasourceId::AlpineApkArchive),
749 ..Default::default()
750 }
751 }
752 }]
753 }
754}
755
756fn apk_contains_pkginfo(path: &Path) -> bool {
757 use flate2::read::GzDecoder;
758
759 let file = match std::fs::File::open(path) {
760 Ok(file) => file,
761 Err(_) => return false,
762 };
763
764 let decoder = GzDecoder::new(file);
765 let mut archive = tar::Archive::new(decoder);
766 let entries = match archive.entries() {
767 Ok(entries) => entries,
768 Err(_) => return false,
769 };
770
771 for entry_result in entries {
772 let entry = match entry_result {
773 Ok(entry) => entry,
774 Err(_) => return false,
775 };
776 let entry_path = match entry.path() {
777 Ok(path) => path,
778 Err(_) => return false,
779 };
780
781 if entry_path.ends_with(".PKGINFO") {
782 return true;
783 }
784 }
785
786 false
787}
788
789fn extract_apk_archive(path: &Path) -> Result<PackageData, String> {
790 use flate2::read::GzDecoder;
791 use std::io::Read;
792
793 let file = std::fs::File::open(path).map_err(|e| format!("Failed to open .apk file: {}", e))?;
794
795 let decoder = GzDecoder::new(file);
796 let mut archive = tar::Archive::new(decoder);
797
798 for entry_result in archive
799 .entries()
800 .map_err(|e| format!("Failed to read tar entries: {}", e))?
801 {
802 let mut entry = entry_result.map_err(|e| format!("Failed to read tar entry: {}", e))?;
803
804 let entry_path = entry
805 .path()
806 .map_err(|e| format!("Failed to get entry path: {}", e))?;
807
808 if entry_path.ends_with(".PKGINFO") {
809 let mut content = String::new();
810 entry
811 .read_to_string(&mut content)
812 .map_err(|e| format!("Failed to read .PKGINFO: {}", e))?;
813
814 return Ok(parse_pkginfo(&content));
815 }
816 }
817
818 Err(".apk archive does not contain .PKGINFO file".to_string())
819}
820
821fn parse_pkginfo(content: &str) -> PackageData {
822 let mut fields: HashMap<&str, Vec<&str>> = HashMap::new();
823
824 for line in content.lines() {
825 let line = line.trim();
826 if line.is_empty() || line.starts_with('#') {
827 continue;
828 }
829
830 if let Some((key, value)) = line.split_once(" = ") {
831 fields.entry(key.trim()).or_default().push(value.trim());
832 }
833 }
834
835 let name = fields
836 .get("pkgname")
837 .and_then(|v| v.first())
838 .map(|s| s.to_string());
839 let pkgver = fields.get("pkgver").and_then(|v| v.first());
840 let version = pkgver.map(|s| s.to_string());
841 let arch = fields
842 .get("arch")
843 .and_then(|v| v.first())
844 .map(|s| s.to_string());
845 let license = fields
846 .get("license")
847 .and_then(|v| v.first())
848 .map(|s| s.to_string());
849 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
850 build_alpine_license_data(license.as_deref());
851 let description = fields
852 .get("pkgdesc")
853 .and_then(|v| v.first())
854 .map(|s| s.to_string());
855 let homepage = fields
856 .get("url")
857 .and_then(|v| v.first())
858 .map(|s| s.to_string());
859 let origin = fields
860 .get("origin")
861 .and_then(|v| v.first())
862 .map(|s| s.to_string());
863 let maintainer_str = fields.get("maintainer").and_then(|v| v.first());
864
865 let mut parties = Vec::new();
866 if let Some(maint) = maintainer_str {
867 let (maint_name, maint_email) = split_name_email(maint);
868 parties.push(Party {
869 r#type: Some("person".to_string()),
870 role: Some("maintainer".to_string()),
871 name: maint_name,
872 email: maint_email,
873 url: None,
874 organization: None,
875 organization_url: None,
876 timezone: None,
877 });
878 }
879
880 let purl = name
881 .as_ref()
882 .and_then(|n| build_alpine_purl(n, version.as_deref(), arch.as_deref()));
883
884 let mut dependencies = Vec::new();
885 if let Some(depends_list) = fields.get("depend") {
886 for dep_str in depends_list {
887 let dep_name = dep_str.split_whitespace().next().unwrap_or(dep_str);
888 dependencies.push(Dependency {
889 purl: Some(format!("pkg:alpine/{}", dep_name)),
890 extracted_requirement: Some(dep_str.to_string()),
891 scope: Some("runtime".to_string()),
892 is_runtime: Some(true),
893 is_optional: Some(false),
894 is_pinned: None,
895 is_direct: Some(true),
896 resolved_package: None,
897 extra_data: None,
898 });
899 }
900 }
901
902 PackageData {
903 datasource_id: Some(DatasourceId::AlpineApkArchive),
904 package_type: Some(PACKAGE_TYPE),
905 namespace: Some("alpine".to_string()),
906 name,
907 version,
908 description,
909 homepage_url: homepage,
910 declared_license_expression,
911 declared_license_expression_spdx,
912 license_detections,
913 extracted_license_statement: license,
914 parties,
915 dependencies,
916 purl,
917 extra_data: origin.map(|o| {
918 let mut map = HashMap::new();
919 map.insert("origin".to_string(), serde_json::Value::String(o));
920 map
921 }),
922 ..Default::default()
923 }
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use std::io::Write;
930 use std::path::PathBuf;
931 use tempfile::TempDir;
932
933 fn create_temp_installed_db(content: &str) -> (TempDir, PathBuf) {
936 let temp_dir = TempDir::new().expect("Failed to create temp dir");
937 let db_dir = temp_dir.path().join("lib/apk/db");
938 std::fs::create_dir_all(&db_dir).expect("Failed to create db dir");
939 let file_path = db_dir.join("installed");
940 let mut file = std::fs::File::create(&file_path).expect("Failed to create file");
941 file.write_all(content.as_bytes())
942 .expect("Failed to write content");
943 (temp_dir, file_path)
944 }
945
946 #[test]
947 fn test_alpine_parser_is_match() {
948 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
949 "/lib/apk/db/installed"
950 )));
951 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
952 "/var/lib/apk/db/installed"
953 )));
954 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
955 "/lib/apk/db/status"
956 )));
957 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
958 "installed"
959 )));
960 }
961
962 #[test]
963 fn test_parse_alpine_package_basic() {
964 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
965P:alpine-baselayout-data
966V:3.2.0-r22
967A:x86_64
968S:11435
969I:73728
970T:Alpine base dir structure and init scripts
971U:https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout
972L:GPL-2.0-only
973o:alpine-baselayout
974m:Natanael Copa <ncopa@alpinelinux.org>
975t:1655134784
976c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
977
978";
979 let (_dir, path) = create_temp_installed_db(content);
980 let pkg = AlpineInstalledParser::extract_first_package(&path);
981 assert_eq!(pkg.name, Some("alpine-baselayout-data".to_string()));
982 assert_eq!(pkg.version, Some("3.2.0-r22".to_string()));
983 assert_eq!(pkg.namespace, Some("alpine".to_string()));
984 assert_eq!(
985 pkg.description,
986 Some("Alpine base dir structure and init scripts".to_string())
987 );
988 assert_eq!(
989 pkg.homepage_url,
990 Some("https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout".to_string())
991 );
992 assert_eq!(
993 pkg.extracted_license_statement,
994 Some("GPL-2.0-only".to_string())
995 );
996 assert_eq!(pkg.parties.len(), 1);
997 assert_eq!(pkg.parties[0].name, Some("Natanael Copa".to_string()));
998 assert_eq!(
999 pkg.parties[0].email,
1000 Some("ncopa@alpinelinux.org".to_string())
1001 );
1002 assert!(
1003 pkg.purl
1004 .as_ref()
1005 .unwrap()
1006 .contains("alpine-baselayout-data")
1007 );
1008 assert!(pkg.purl.as_ref().unwrap().contains("arch=x86_64"));
1009 }
1010
1011 #[test]
1012 fn test_parse_alpine_with_dependencies() {
1013 let content = "P:musl
1014V:1.2.3-r0
1015A:x86_64
1016D:scanelf so:libc.musl-x86_64.so.1
1017
1018";
1019 let (_dir, path) = create_temp_installed_db(content);
1020 let pkg = AlpineInstalledParser::extract_first_package(&path);
1021 assert_eq!(pkg.name, Some("musl".to_string()));
1022 assert_eq!(pkg.dependencies.len(), 1);
1023 assert!(
1024 pkg.dependencies[0]
1025 .purl
1026 .as_ref()
1027 .unwrap()
1028 .contains("scanelf")
1029 );
1030 }
1031
1032 #[test]
1033 fn test_build_alpine_purl() {
1034 let purl = build_alpine_purl("busybox", Some("1.31.1-r9"), Some("x86_64"));
1035 assert_eq!(
1036 purl,
1037 Some("pkg:alpine/busybox@1.31.1-r9?arch=x86_64".to_string())
1038 );
1039
1040 let purl_no_arch = build_alpine_purl("package", Some("1.0"), None);
1041 assert_eq!(purl_no_arch, Some("pkg:alpine/package@1.0".to_string()));
1042 }
1043
1044 #[test]
1045 fn test_parse_alpine_extra_data() {
1046 let content = "P:test-package
1047V:1.0
1048C:base64checksum==
1049S:12345
1050I:67890
1051t:1234567890
1052c:gitcommithash
1053
1054";
1055 let (_dir, path) = create_temp_installed_db(content);
1056 let pkg = AlpineInstalledParser::extract_first_package(&path);
1057 assert!(pkg.extra_data.is_some());
1058 let extra = pkg.extra_data.as_ref().unwrap();
1059 assert_eq!(extra["checksum"], "base64checksum==");
1060 assert_eq!(extra["compressed_size"], "12345");
1061 assert_eq!(extra["installed_size"], "67890");
1062 assert_eq!(extra["build_timestamp"], "1234567890");
1063 assert_eq!(extra["git_commit"], "gitcommithash");
1064 }
1065
1066 #[test]
1067 fn test_parse_alpine_case_sensitive_keys() {
1068 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1069P:test-pkg
1070V:1.0
1071T:A test description
1072t:1655134784
1073c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1074
1075";
1076 let (_dir, path) = create_temp_installed_db(content);
1077 let pkg = AlpineInstalledParser::extract_first_package(&path);
1078 assert_eq!(pkg.description, Some("A test description".to_string()));
1079 let extra = pkg.extra_data.as_ref().unwrap();
1080 assert_eq!(extra["checksum"], "Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=");
1081 assert_eq!(extra["build_timestamp"], "1655134784");
1082 assert_eq!(
1083 extra["git_commit"],
1084 "cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_parse_alpine_multiple_packages() {
1090 let content = "P:package1
1091V:1.0
1092A:x86_64
1093
1094P:package2
1095V:2.0
1096A:aarch64
1097
1098";
1099 let (_dir, path) = create_temp_installed_db(content);
1100 let pkgs = AlpineInstalledParser::extract_packages(&path);
1101 assert_eq!(pkgs.len(), 2);
1102 assert_eq!(pkgs[0].name, Some("package1".to_string()));
1103 assert_eq!(pkgs[0].version, Some("1.0".to_string()));
1104 assert_eq!(pkgs[1].name, Some("package2".to_string()));
1105 assert_eq!(pkgs[1].version, Some("2.0".to_string()));
1106 }
1107
1108 #[test]
1109 fn test_parse_alpine_file_references() {
1110 let content = "P:test-pkg
1111V:1.0
1112F:usr/bin
1113R:test
1114Z:Q1WTc55xfvPogzA0YUV24D0Ym+MKE=
1115F:etc
1116R:config
1117Z:Q1pcfTfDNEbNKQc2s1tia7da05M8Q=
1118
1119";
1120 let (_dir, path) = create_temp_installed_db(content);
1121 let pkg = AlpineInstalledParser::extract_first_package(&path);
1122 assert_eq!(pkg.file_references.len(), 2);
1123 assert_eq!(pkg.file_references[0].path, "usr/bin/test");
1124 assert!(pkg.file_references[0].sha1.is_some());
1125 assert_eq!(pkg.file_references[1].path, "etc/config");
1126 assert!(pkg.file_references[1].sha1.is_some());
1127 }
1128
1129 #[test]
1130 fn test_parse_alpine_empty_fields() {
1131 let content = "P:minimal-package
1132V:1.0
1133
1134";
1135 let (_dir, path) = create_temp_installed_db(content);
1136 let pkg = AlpineInstalledParser::extract_first_package(&path);
1137 assert_eq!(pkg.name, Some("minimal-package".to_string()));
1138 assert_eq!(pkg.version, Some("1.0".to_string()));
1139 assert!(pkg.description.is_none());
1140 assert!(pkg.homepage_url.is_none());
1141 assert_eq!(pkg.dependencies.len(), 0);
1142 }
1143
1144 #[test]
1145 fn test_parse_alpine_origin_field() {
1146 let content = "P:busybox-ifupdown
1147V:1.35.0-r13
1148o:busybox
1149A:x86_64
1150
1151";
1152 let (_dir, path) = create_temp_installed_db(content);
1153 let pkg = AlpineInstalledParser::extract_first_package(&path);
1154 assert_eq!(pkg.name, Some("busybox-ifupdown".to_string()));
1155 assert_eq!(pkg.source_packages.len(), 1);
1156 assert_eq!(pkg.source_packages[0], "pkg:alpine/busybox");
1157 }
1158
1159 #[test]
1160 fn test_parse_alpine_url_field() {
1161 let content = "P:openssl
1162V:1.1.1q-r0
1163U:https://www.openssl.org
1164A:x86_64
1165
1166";
1167 let (_dir, path) = create_temp_installed_db(content);
1168 let pkg = AlpineInstalledParser::extract_first_package(&path);
1169 assert_eq!(
1170 pkg.homepage_url,
1171 Some("https://www.openssl.org".to_string())
1172 );
1173 }
1174
1175 #[test]
1176 fn test_parse_alpine_provider_field() {
1177 let content = "P:some-package
1178V:1.0
1179p:cmd:binary=1.0
1180p:so:libtest.so.1
1181
1182";
1183 let (_dir, path) = create_temp_installed_db(content);
1184 let pkg = AlpineInstalledParser::extract_first_package(&path);
1185 assert!(pkg.extra_data.is_some());
1186 let extra = pkg.extra_data.as_ref().unwrap();
1187 let providers = extra.get("providers").and_then(|v| v.as_array());
1188 assert!(providers.is_some());
1189 let provider_array = providers.unwrap();
1190 assert_eq!(provider_array.len(), 2);
1191 assert_eq!(provider_array[0].as_str(), Some("cmd:binary=1.0"));
1192 assert_eq!(provider_array[1].as_str(), Some("so:libtest.so.1"));
1193 }
1194
1195 #[test]
1196 fn test_alpine_apk_parser_is_match() {
1197 let apk_path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1198
1199 assert!(AlpineApkParser::is_match(&apk_path));
1200 assert!(!AlpineApkParser::is_match(&PathBuf::from("package.tar.gz")));
1201 assert!(!AlpineApkParser::is_match(&PathBuf::from("installed")));
1202 }
1203
1204 #[test]
1205 fn test_alpine_apk_parser_rejects_android_and_placeholder_apk_fixtures() {
1206 let android_apk = PathBuf::from("testdata/misc/test_android.apk");
1207 let placeholder_alpine_apk = PathBuf::from("testdata/misc/test_alpine.apk");
1208 let valid_alpine_apk = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1209
1210 assert!(!AlpineApkParser::is_match(&android_apk));
1211 assert!(!AlpineApkParser::is_match(&placeholder_alpine_apk));
1212 assert!(AlpineApkParser::is_match(&valid_alpine_apk));
1213 }
1214
1215 #[test]
1216 fn test_alpine_apkbuild_parser_is_match() {
1217 assert!(AlpineApkbuildParser::is_match(&PathBuf::from("APKBUILD")));
1218 assert!(AlpineApkbuildParser::is_match(&PathBuf::from(
1219 "/path/to/APKBUILD"
1220 )));
1221 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from("apkbuild")));
1222 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from(
1223 "APKBUILD.txt"
1224 )));
1225 }
1226
1227 #[test]
1228 fn test_parse_apkbuild_icu_reference() {
1229 let path = PathBuf::from("testdata/alpine-fixtures/apkbuild/alpine14/main/icu/APKBUILD");
1230 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1231
1232 assert_eq!(pkg.datasource_id, Some(DatasourceId::AlpineApkbuild));
1233 assert_eq!(pkg.name.as_deref(), Some("icu"));
1234 assert_eq!(pkg.version.as_deref(), Some("67.1-r2"));
1235 assert_eq!(
1236 pkg.description.as_deref(),
1237 Some("International Components for Unicode library")
1238 );
1239 assert_eq!(
1240 pkg.homepage_url.as_deref(),
1241 Some("http://site.icu-project.org/")
1242 );
1243 assert_eq!(
1244 pkg.extracted_license_statement.as_deref(),
1245 Some("MIT ICU Unicode-TOU")
1246 );
1247 assert_eq!(
1248 pkg.declared_license_expression_spdx.as_deref(),
1249 Some("ICU AND MIT AND Unicode-TOU")
1250 );
1251 assert_eq!(pkg.dependencies.len(), 3);
1252 let depends_dev = pkg
1253 .dependencies
1254 .iter()
1255 .find(|dep| dep.scope.as_deref() == Some("depends_dev"))
1256 .expect("depends_dev dependency missing");
1257 assert_eq!(depends_dev.purl.as_deref(), Some("pkg:alpine/icu"));
1258 assert_eq!(depends_dev.is_runtime, Some(false));
1259 assert_eq!(depends_dev.is_optional, Some(true));
1260
1261 let check_dep_names: Vec<_> = pkg
1262 .dependencies
1263 .iter()
1264 .filter(|dep| dep.scope.as_deref() == Some("checkdepends"))
1265 .filter_map(|dep| dep.purl.as_deref())
1266 .collect();
1267 assert!(check_dep_names.contains(&"pkg:alpine/diffutils"));
1268 assert!(check_dep_names.contains(&"pkg:alpine/python3"));
1269 let extra = pkg.extra_data.as_ref().unwrap();
1270 assert!(extra.contains_key("sources"));
1271 assert!(extra.contains_key("checksums"));
1272 }
1273
1274 #[test]
1275 fn test_parse_apkbuild_custom_multiple_license_uses_raw_matched_text() {
1276 let path = PathBuf::from(
1277 "testdata/alpine-fixtures/apkbuild/alpine13/main/linux-firmware/APKBUILD",
1278 );
1279 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1280
1281 assert_eq!(pkg.name.as_deref(), Some("linux-firmware"));
1282 assert_eq!(pkg.version.as_deref(), Some("20201218-r0"));
1283 assert_eq!(
1284 pkg.extracted_license_statement.as_deref(),
1285 Some("custom:multiple")
1286 );
1287 assert_eq!(
1288 pkg.declared_license_expression.as_deref(),
1289 Some("unknown-license-reference")
1290 );
1291 assert_eq!(
1292 pkg.declared_license_expression_spdx.as_deref(),
1293 Some("LicenseRef-provenant-unknown-license-reference")
1294 );
1295 let matched = pkg.license_detections[0].matches[0].matched_text.as_deref();
1296 assert_eq!(matched, Some("custom:multiple"));
1297 }
1298
1299 #[test]
1300 fn test_parse_alpine_no_files_package_still_detected() {
1301 let path = PathBuf::from("testdata/alpine-fixtures/full-installed/installed");
1302 let content = std::fs::read_to_string(&path).expect("read installed db fixture");
1303 let packages = parse_alpine_installed_db(&content);
1304 let libc_utils = packages
1305 .into_iter()
1306 .find(|pkg| pkg.name.as_deref() == Some("libc-utils"))
1307 .expect("libc-utils package should exist");
1308
1309 assert_eq!(libc_utils.file_references.len(), 0);
1310 assert!(
1311 libc_utils
1312 .purl
1313 .as_deref()
1314 .is_some_and(|p| p.contains("libc-utils"))
1315 );
1316 }
1317
1318 #[test]
1319 fn test_parse_alpine_commit_generates_https_vcs_url() {
1320 let content =
1321 "P:test-package\nV:1.0-r0\nA:x86_64\nc:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd\n";
1322 let (_dir, path) = create_temp_installed_db(content);
1323 let pkg = AlpineInstalledParser::extract_first_package(&path);
1324
1325 assert_eq!(
1326 pkg.vcs_url.as_deref(),
1327 Some(
1328 "git+https://git.alpinelinux.org/aports/commit/?id=cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1329 )
1330 );
1331 }
1332
1333 #[test]
1334 fn test_parse_alpine_virtual_package() {
1335 let content = "P:.postgis-rundeps
1336V:20210104.190748
1337A:noarch
1338S:0
1339I:0
1340T:virtual meta package
1341U:
1342L:
1343D:json-c geos gdal proj protobuf-c libstdc++
1344
1345";
1346 let (_dir, path) = create_temp_installed_db(content);
1347 let pkg = AlpineInstalledParser::extract_first_package(&path);
1348 assert_eq!(pkg.name, Some(".postgis-rundeps".to_string()));
1349 assert_eq!(pkg.version, Some("20210104.190748".to_string()));
1350 assert_eq!(pkg.description, Some("virtual meta package".to_string()));
1351 assert!(pkg.extra_data.is_some());
1352 let extra = pkg.extra_data.as_ref().unwrap();
1353 assert_eq!(
1354 extra.get("is_virtual").and_then(|v| v.as_bool()),
1355 Some(true)
1356 );
1357 assert_eq!(pkg.dependencies.len(), 6);
1358 assert!(pkg.homepage_url.is_none());
1359 assert!(pkg.extracted_license_statement.is_none());
1360 }
1361
1362 #[test]
1363 fn test_installed_db_license_normalization() {
1364 let content = "P:test-package\nV:1.0-r0\nA:x86_64\nL:MIT\n\n";
1365 let (_dir, path) = create_temp_installed_db(content);
1366 let pkg = AlpineInstalledParser::extract_first_package(&path);
1367
1368 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1369 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
1370 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
1371 assert_eq!(pkg.license_detections.len(), 1);
1372 }
1373
1374 #[test]
1375 fn test_apk_archive_license_normalization() {
1376 let path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1377 let pkg = AlpineApkParser::extract_first_package(&path);
1378
1379 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1380 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
1381 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
1382 assert_eq!(pkg.license_detections.len(), 1);
1383 }
1384}
1385
1386crate::register_parser!(
1387 "Alpine Linux package (installed db and .apk archive)",
1388 &["**/lib/apk/db/installed", "**/*.apk"],
1389 "alpine",
1390 "",
1391 Some("https://wiki.alpinelinux.org/wiki/Apk_spec"),
1392);
1393
1394crate::register_parser!(
1395 "Alpine Linux APKBUILD recipe",
1396 &["**/APKBUILD"],
1397 "alpine",
1398 "Shell",
1399 Some("https://wiki.alpinelinux.org/wiki/APKBUILD_Reference"),
1400);