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