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()
91 .and_then(|n| n.to_str())
92 .is_some_and(|name| {
93 name == "APKBUILD" || name.ends_with("-APKBUILD") || name.ends_with("_APKBUILD")
94 })
95 }
96
97 fn extract_packages(path: &Path) -> Vec<PackageData> {
98 let content = match read_file_to_string(path, None) {
99 Ok(c) => c,
100 Err(e) => {
101 warn!("Failed to read APKBUILD {:?}: {}", path, e);
102 return vec![default_package_data(DatasourceId::AlpineApkbuild)];
103 }
104 };
105
106 vec![parse_apkbuild(&content)]
107 }
108}
109
110fn parse_alpine_installed_db(content: &str) -> Vec<PackageData> {
111 let raw_paragraphs: Vec<&str> = content
112 .split("\n\n")
113 .filter(|p| !p.trim().is_empty())
114 .collect();
115
116 let mut all_packages = Vec::new();
117
118 for raw_text in raw_paragraphs.iter().take(MAX_ITERATION_COUNT) {
119 let headers = parse_alpine_headers(raw_text);
120 let pkg = parse_alpine_package_paragraph(&headers, raw_text);
121 if pkg.name.is_some() {
122 all_packages.push(pkg);
123 }
124 }
125
126 if all_packages.is_empty() {
127 return vec![default_package_data(DatasourceId::AlpineInstalledDb)];
128 }
129
130 all_packages
131}
132
133fn parse_alpine_headers(content: &str) -> HashMap<String, Vec<String>> {
139 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
140
141 for line in content.lines().take(MAX_ITERATION_COUNT) {
142 if line.is_empty() {
143 continue;
144 }
145
146 if let Some((key, value)) = line.split_once(':') {
147 let key = key.trim();
148 let value = value.trim();
149 if !key.is_empty() && !value.is_empty() {
150 headers
151 .entry(key.to_string())
152 .or_default()
153 .push(value.to_string());
154 }
155 }
156 }
157
158 headers
159}
160
161fn get_first(headers: &HashMap<String, Vec<String>>, key: &str) -> Option<String> {
162 headers
163 .get(key)
164 .and_then(|values| values.first())
165 .map(|v| truncate_field(v.trim().to_string()))
166}
167
168fn get_all(headers: &HashMap<String, Vec<String>>, key: &str) -> Vec<String> {
169 headers
170 .get(key)
171 .cloned()
172 .unwrap_or_default()
173 .into_iter()
174 .filter(|v| !v.trim().is_empty())
175 .collect()
176}
177
178fn parse_alpine_package_paragraph(
179 headers: &HashMap<String, Vec<String>>,
180 raw_text: &str,
181) -> PackageData {
182 let name = get_first(headers, "P");
183 let version = get_first(headers, "V");
184 let description = get_first(headers, "T");
185 let homepage_url = get_first(headers, "U");
186 let architecture = get_first(headers, "A");
187
188 let is_virtual = description
189 .as_ref()
190 .is_some_and(|d| d == "virtual meta package");
191
192 let namespace = Some("alpine".to_string());
193 let mut parties = Vec::new();
194
195 if let Some(maintainer) = get_first(headers, "m") {
196 let (name_opt, email_opt) = split_name_email(&maintainer);
197 parties.push(Party {
198 r#type: None,
199 role: Some("maintainer".to_string()),
200 name: name_opt,
201 email: email_opt,
202 url: None,
203 organization: None,
204 organization_url: None,
205 timezone: None,
206 });
207 }
208
209 let extracted_license_statement = get_first(headers, "L");
210 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
211 build_alpine_license_data(extracted_license_statement.as_deref());
212
213 let source_packages = if let Some(origin) = get_first(headers, "o") {
214 vec![format!("pkg:alpine/{}", origin)]
215 } else {
216 Vec::new()
217 };
218 let vcs_url = get_first(headers, "c").map(|commit| {
219 truncate_field(format!(
220 "git+https://git.alpinelinux.org/aports/commit/?id={commit}"
221 ))
222 });
223
224 let mut dependencies = Vec::new();
225 let mut dep_count = 0;
226 'dep_loop: for dep in get_all(headers, "D") {
227 for dep_str in dep.split_whitespace() {
228 if dep_str.starts_with("so:") || dep_str.starts_with("cmd:") {
229 continue;
230 }
231
232 dep_count += 1;
233 if dep_count > MAX_ITERATION_COUNT {
234 warn!("Exceeded MAX_ITERATION_COUNT in dependency parsing, truncating");
235 break 'dep_loop;
236 }
237
238 dependencies.push(Dependency {
239 purl: Some(format!("pkg:alpine/{}", dep_str)),
240 extracted_requirement: None,
241 scope: Some("install".to_string()),
242 is_runtime: Some(true),
243 is_optional: Some(false),
244 is_direct: Some(true),
245 resolved_package: None,
246 extra_data: None,
247 is_pinned: Some(false),
248 });
249 }
250 }
251
252 let mut extra_data = HashMap::new();
253
254 if is_virtual {
255 extra_data.insert("is_virtual".to_string(), true.into());
256 }
257
258 if let Some(checksum) = get_first(headers, "C") {
259 extra_data.insert("checksum".to_string(), checksum.into());
260 }
261
262 if let Some(size) = get_first(headers, "S") {
263 extra_data.insert("compressed_size".to_string(), size.into());
264 }
265
266 if let Some(installed_size) = get_first(headers, "I") {
267 extra_data.insert("installed_size".to_string(), installed_size.into());
268 }
269
270 if let Some(timestamp) = get_first(headers, "t") {
271 extra_data.insert("build_timestamp".to_string(), timestamp.into());
272 }
273
274 if let Some(commit) = get_first(headers, "c") {
275 extra_data.insert("git_commit".to_string(), commit.into());
276 }
277
278 let providers = extract_providers(raw_text);
279 if !providers.is_empty() {
280 let provider_list: Vec<serde_json::Value> =
281 providers.into_iter().map(|s| s.into()).collect();
282 extra_data.insert("providers".to_string(), provider_list.into());
283 }
284
285 let file_references = extract_file_references(raw_text);
286
287 PackageData {
288 datasource_id: Some(DatasourceId::AlpineInstalledDb),
289 package_type: Some(PACKAGE_TYPE),
290 namespace: namespace.clone(),
291 name: name.clone(),
292 version: version.clone(),
293 description,
294 homepage_url,
295 vcs_url,
296 parties,
297 declared_license_expression,
298 declared_license_expression_spdx,
299 license_detections,
300 extracted_license_statement,
301 source_packages,
302 dependencies,
303 file_references,
304 purl: name
305 .as_ref()
306 .and_then(|n| build_alpine_purl(n, version.as_deref(), architecture.as_deref())),
307 extra_data: if extra_data.is_empty() {
308 None
309 } else {
310 Some(extra_data)
311 },
312 ..Default::default()
313 }
314}
315
316fn parse_apkbuild(content: &str) -> PackageData {
317 let variables = parse_apkbuild_variables(content);
318
319 let name = variables
320 .get("pkgname")
321 .cloned()
322 .map(|value| strip_apkbuild_quote_chars(&value))
323 .map(truncate_field);
324 let version = match (variables.get("pkgver"), variables.get("pkgrel")) {
325 (Some(ver), Some(rel)) => Some(truncate_field(format!("{}-r{}", ver, rel))),
326 (Some(ver), None) => Some(truncate_field(ver.clone())),
327 _ => None,
328 };
329 let description = variables.get("pkgdesc").cloned().map(truncate_field);
330 let homepage_url = variables.get("url").cloned().map(truncate_field);
331 let extracted_license_statement = variables.get("license").cloned().map(truncate_field);
332 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
333 build_alpine_license_data(extracted_license_statement.as_deref());
334
335 let dependencies = parse_apkbuild_dependencies(&variables);
336
337 let mut extra_data = HashMap::new();
338 if let Some(source) = variables.get("source") {
339 let sources_value: Vec<serde_json::Value> = parse_apkbuild_sources(source)
340 .into_iter()
341 .map(|(file_name, url)| serde_json::json!({ "file_name": file_name, "url": url }))
342 .collect();
343 if !sources_value.is_empty() {
344 extra_data.insert(
345 "sources".to_string(),
346 serde_json::Value::Array(sources_value),
347 );
348 }
349 }
350 for (field, checksum_key) in [
351 ("sha512sums", "sha512"),
352 ("sha256sums", "sha256"),
353 ("md5sums", "md5"),
354 ] {
355 if let Some(checksums) = variables.get(field) {
356 let checksum_entries: Vec<serde_json::Value> = parse_apkbuild_checksums(checksums)
357 .into_iter()
358 .map(|(file_name, checksum)| serde_json::json!({ "file_name": file_name, checksum_key: checksum }))
359 .collect();
360 if !checksum_entries.is_empty() {
361 match extra_data.get_mut("checksums") {
362 Some(serde_json::Value::Array(existing)) => existing.extend(checksum_entries),
363 _ => {
364 extra_data.insert(
365 "checksums".to_string(),
366 serde_json::Value::Array(checksum_entries),
367 );
368 }
369 }
370 }
371 }
372 }
373
374 PackageData {
375 datasource_id: Some(DatasourceId::AlpineApkbuild),
376 package_type: Some(PACKAGE_TYPE),
377 namespace: None,
378 name: name.clone(),
379 version: version.clone(),
380 description,
381 homepage_url,
382 extracted_license_statement,
383 declared_license_expression,
384 declared_license_expression_spdx,
385 license_detections,
386 dependencies,
387 purl: name
388 .as_deref()
389 .and_then(|n| build_alpine_purl(n, version.as_deref(), None)),
390 extra_data: (!extra_data.is_empty()).then_some(extra_data),
391 ..default_package_data(DatasourceId::AlpineApkbuild)
392 }
393}
394
395const APKBUILD_CAPTURED_FIELDS: &[&str] = &[
396 "pkgname",
397 "pkgver",
398 "pkgrel",
399 "pkgdesc",
400 "url",
401 "license",
402 "source",
403 "depends",
404 "depends_dev",
405 "makedepends",
406 "makedepends_build",
407 "makedepends_host",
408 "checkdepends",
409 "sha512sums",
410 "sha256sums",
411 "md5sums",
412];
413
414fn parse_apkbuild_variables(content: &str) -> HashMap<String, String> {
415 let mut resolved_variables = HashMap::new();
416 let mut lines = content.lines().peekable();
417 let mut brace_depth = 0usize;
418 let mut line_count = 0usize;
419
420 while let Some(line) = lines.next() {
421 line_count += 1;
422 if line_count > MAX_ITERATION_COUNT {
423 warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_variables, truncating");
424 break;
425 }
426 let trimmed = line.trim();
427 if trimmed.is_empty() || trimmed.starts_with('#') {
428 continue;
429 }
430 if trimmed.ends_with("(){") || trimmed.ends_with("() {") {
431 brace_depth += 1;
432 continue;
433 }
434 if brace_depth > 0 {
435 brace_depth += trimmed.chars().filter(|c| *c == '{').count();
436 brace_depth = brace_depth.saturating_sub(trimmed.chars().filter(|c| *c == '}').count());
437 continue;
438 }
439 let Some((name, value)) = trimmed.split_once('=') else {
440 continue;
441 };
442 let mut value = value.trim().to_string();
443 if starts_with_apkbuild_quote(&value) && !has_closed_apkbuild_quote(&value) {
444 while let Some(next) = lines.peek() {
445 value.push('\n');
446 value.push_str(next);
447 if lines.next().is_none() {
448 break;
449 }
450 if has_closed_apkbuild_quote(&value) {
451 break;
452 }
453 }
454 }
455 let name = name.trim().to_string();
456 if name == "pkgname" && resolved_variables.contains_key(name.as_str()) {
457 continue;
458 }
459 let value = strip_apkbuild_inline_comment(&value).trim();
460 let value = resolve_apkbuild_value(value, &resolved_variables);
461 if let Some(existing) = resolved_variables.get(&name)
462 && !existing.contains('$')
463 && value.contains('$')
464 {
465 continue;
466 }
467 resolved_variables.insert(name, value);
468 }
469
470 let mut resolved = HashMap::new();
471 for key in APKBUILD_CAPTURED_FIELDS {
472 if let Some(value) = resolved_variables.get(*key) {
473 resolved.insert(
474 (*key).to_string(),
475 resolve_apkbuild_value(value, &resolved_variables),
476 );
477 }
478 }
479 resolved
480}
481
482fn resolve_apkbuild_value(value: &str, variables: &HashMap<String, String>) -> String {
483 let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
484 if variables.is_empty() || !resolved.contains('$') {
485 return resolved;
486 }
487
488 for _ in 0..8 {
489 let mut changed = false;
490 changed |= replace_apkbuild_parameter_expressions(&mut resolved, variables);
491 for (name, raw_value) in variables {
492 let value_resolved = strip_wrapping_quotes(raw_value.trim());
493 changed |= replace_apkbuild_placeholder(
494 &mut resolved,
495 &format!("${{{name}//./-}}"),
496 &value_resolved.replace('.', "-"),
497 );
498 changed |= replace_apkbuild_placeholder(
499 &mut resolved,
500 &format!("${{{name}//./_}}"),
501 &value_resolved.replace('.', "_"),
502 );
503 changed |= replace_apkbuild_placeholder(
504 &mut resolved,
505 &format!("${{{name}::8}}"),
506 &value_resolved.chars().take(8).collect::<String>(),
507 );
508 changed |= replace_apkbuild_placeholder(
509 &mut resolved,
510 &format!("${{{name}}}"),
511 value_resolved,
512 );
513 }
514 changed |= replace_all_bare_apkbuild_variables(&mut resolved, variables);
515 if !changed || !resolved.contains('$') {
516 break;
517 }
518 }
519 resolved
520}
521
522fn replace_apkbuild_placeholder(
523 resolved: &mut String,
524 placeholder: &str,
525 replacement: &str,
526) -> bool {
527 if !resolved.contains(placeholder) {
528 return false;
529 }
530
531 *resolved = resolved.replace(placeholder, replacement);
532 true
533}
534
535fn replace_apkbuild_parameter_expressions(
536 resolved: &mut String,
537 variables: &HashMap<String, String>,
538) -> bool {
539 if !resolved.contains('$') {
540 return false;
541 }
542
543 let mut changed = false;
544 let mut output = String::with_capacity(resolved.len());
545 let mut rest = resolved.as_str();
546
547 while let Some(index) = rest.find('$') {
548 output.push_str(&rest[..index]);
549 rest = &rest[index..];
550
551 if let Some(stripped) = rest.strip_prefix("$(")
552 && let Some(expr) = stripped.strip_prefix('(')
553 && let Some(end) = expr.find("))")
554 && let Some(value) = evaluate_apkbuild_arithmetic_expression(&expr[..end], variables)
555 {
556 output.push_str(&value);
557 rest = &expr[end + 2..];
558 changed = true;
559 continue;
560 }
561
562 if let Some(expr) = rest.strip_prefix("${")
563 && let Some(end) = expr.find('}')
564 && let Some(value) = evaluate_apkbuild_parameter_expression(&expr[..end], variables)
565 {
566 output.push_str(&value);
567 rest = &expr[end + 1..];
568 changed = true;
569 continue;
570 }
571
572 output.push('$');
573 rest = &rest['$'.len_utf8()..];
574 }
575
576 if !changed {
577 return false;
578 }
579
580 output.push_str(rest);
581 *resolved = output;
582 true
583}
584
585fn evaluate_apkbuild_parameter_expression(
586 expr: &str,
587 variables: &HashMap<String, String>,
588) -> Option<String> {
589 if let Some((name, default)) = expr.split_once(":-") {
590 return Some(
591 variables
592 .get(name)
593 .filter(|value| !value.is_empty())
594 .cloned()
595 .unwrap_or_else(|| default.to_string()),
596 );
597 }
598
599 if let Some((name, pattern)) = expr.split_once("%%") {
600 let value = variables.get(name)?.as_str();
601 return trim_apkbuild_suffix_pattern(value, pattern, true);
602 }
603
604 if let Some((name, pattern)) = expr.split_once("##") {
605 let value = variables.get(name)?.as_str();
606 return trim_apkbuild_prefix_pattern(value, pattern, true);
607 }
608
609 if let Some((name, pattern)) = expr.split_once('%') {
610 let value = variables.get(name)?.as_str();
611 return trim_apkbuild_suffix_pattern(value, pattern, false);
612 }
613
614 if let Some((name, pattern)) = expr.split_once('#') {
615 let value = variables.get(name)?.as_str();
616 return trim_apkbuild_prefix_pattern(value, pattern, false);
617 }
618
619 if let Some((name, rest)) = expr.split_once("//") {
620 let (from, to) = rest.split_once('/').unwrap_or((rest, ""));
621 let value = variables.get(name)?.as_str();
622 return Some(value.replace(from, to));
623 }
624
625 if let Some((name, rest)) = expr.split_once('/') {
626 let (from, to) = rest.split_once('/')?;
627 let value = variables.get(name)?.as_str();
628 return Some(value.replacen(from, to, 1));
629 }
630
631 if let Some(name) = expr.strip_suffix("::8") {
632 let value = variables.get(name)?.as_str();
633 return Some(value.chars().take(8).collect());
634 }
635
636 Some(variables.get(expr)?.clone())
637}
638
639fn trim_apkbuild_suffix_pattern(value: &str, pattern: &str, longest: bool) -> Option<String> {
640 let matcher = pattern.strip_suffix('*')?;
641 let index = if let Some(class) = matcher.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
642 let chars: Vec<_> = class.chars().collect();
643 if longest {
644 value.char_indices().find(|(_, ch)| chars.contains(ch))?.0
645 } else {
646 value.char_indices().rfind(|(_, ch)| chars.contains(ch))?.0
647 }
648 } else if longest {
649 value.find(matcher)?
650 } else {
651 value.rfind(matcher)?
652 };
653
654 Some(value[..index].to_string())
655}
656
657fn trim_apkbuild_prefix_pattern(value: &str, pattern: &str, longest: bool) -> Option<String> {
658 let matcher = pattern.strip_prefix('*')?;
659 let index = if let Some(class) = matcher.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
660 let chars: Vec<_> = class.chars().collect();
661 let (idx, ch) = if longest {
662 value.char_indices().rfind(|(_, ch)| chars.contains(ch))?
663 } else {
664 value.char_indices().find(|(_, ch)| chars.contains(ch))?
665 };
666 idx + ch.len_utf8()
667 } else if longest {
668 value.rfind(matcher)? + matcher.len()
669 } else {
670 value.find(matcher)? + matcher.len()
671 };
672
673 Some(value[index..].to_string())
674}
675
676fn evaluate_apkbuild_arithmetic_expression(
677 expr: &str,
678 variables: &HashMap<String, String>,
679) -> Option<String> {
680 let mut total = 0i64;
681 let mut sign = 1i64;
682
683 for token in expr.split_whitespace() {
684 match token {
685 "+" => sign = 1,
686 "-" => sign = -1,
687 _ => {
688 let value = token
689 .parse::<i64>()
690 .ok()
691 .or_else(|| variables.get(token)?.parse::<i64>().ok())?;
692 total += sign * value;
693 }
694 }
695 }
696
697 Some(total.to_string())
698}
699
700fn replace_all_bare_apkbuild_variables(
701 resolved: &mut String,
702 variables: &HashMap<String, String>,
703) -> bool {
704 let mut changed = false;
705 let mut output = String::with_capacity(resolved.len());
706 let mut rest = resolved.as_str();
707
708 while let Some(index) = rest.find('$') {
709 output.push_str(&rest[..index]);
710 rest = &rest[index..];
711
712 if rest.starts_with("${") || rest.starts_with("$(") {
713 output.push('$');
714 rest = &rest['$'.len_utf8()..];
715 continue;
716 }
717
718 let Some(first) = rest[1..].chars().next() else {
719 output.push('$');
720 rest = &rest['$'.len_utf8()..];
721 continue;
722 };
723
724 if first == '_' || first.is_ascii_alphabetic() {
725 let mut name_len = first.len_utf8();
726 for ch in rest[1 + name_len..].chars() {
727 if ch == '_' || ch.is_ascii_alphanumeric() {
728 name_len += ch.len_utf8();
729 } else {
730 break;
731 }
732 }
733
734 let name = &rest[1..1 + name_len];
735 if let Some(value) = variables.get(name) {
736 output.push_str(value);
737 rest = &rest[1 + name_len..];
738 changed = true;
739 continue;
740 }
741 }
742
743 output.push('$');
744 rest = &rest['$'.len_utf8()..];
745 }
746
747 if !changed {
748 return false;
749 }
750
751 output.push_str(rest);
752 *resolved = output;
753 true
754}
755
756fn starts_with_apkbuild_quote(value: &str) -> bool {
757 matches!(value.trim_start().chars().next(), Some('"' | '\''))
758}
759
760fn has_closed_apkbuild_quote(value: &str) -> bool {
761 let trimmed = value.trim_start();
762 let Some(quote) = trimmed.chars().next().filter(|c| matches!(c, '"' | '\'')) else {
763 return true;
764 };
765
766 let mut escaped = false;
767 for ch in trimmed.chars().skip(1) {
768 if quote == '"' && escaped {
769 escaped = false;
770 continue;
771 }
772
773 if quote == '"' && ch == '\\' {
774 escaped = true;
775 continue;
776 }
777
778 if ch == quote {
779 return true;
780 }
781 }
782
783 false
784}
785
786fn strip_apkbuild_inline_comment(value: &str) -> &str {
787 let mut in_single = false;
788 let mut in_double = false;
789 let mut escaped = false;
790 let mut parameter_expansion_depth = 0usize;
791
792 let mut iter = value.char_indices().peekable();
793 while let Some((index, ch)) = iter.next() {
794 if escaped {
795 escaped = false;
796 continue;
797 }
798
799 match ch {
800 '$' if !in_single => {
801 if let Some((_, '{')) = iter.peek() {
802 parameter_expansion_depth += 1;
803 }
804 }
805 '\\' if in_double => escaped = true,
806 '\'' if !in_double => in_single = !in_single,
807 '"' if !in_single => in_double = !in_double,
808 '}' if parameter_expansion_depth > 0 && !in_single => {
809 parameter_expansion_depth -= 1;
810 }
811 '#' if !in_single && !in_double && parameter_expansion_depth == 0 => {
812 return value[..index].trim_end();
813 }
814 _ => {}
815 }
816 }
817
818 value.trim_end()
819}
820
821fn strip_apkbuild_quote_chars(value: &str) -> String {
822 value
823 .chars()
824 .filter(|ch| !matches!(ch, '"' | '\''))
825 .collect()
826}
827
828fn strip_wrapping_quotes(value: &str) -> &str {
829 value
830 .strip_prefix('"')
831 .and_then(|v| v.strip_suffix('"'))
832 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
833 .unwrap_or(value)
834}
835
836fn parse_apkbuild_sources(value: &str) -> Vec<(Option<String>, Option<String>)> {
837 value
838 .split_whitespace()
839 .filter(|part| !part.is_empty())
840 .map(|part| {
841 if let Some((file_name, url)) = part.split_once("::") {
842 (Some(file_name.to_string()), Some(url.to_string()))
843 } else if part.contains("://") {
844 (None, Some(part.to_string()))
845 } else {
846 (Some(part.to_string()), None)
847 }
848 })
849 .collect()
850}
851
852fn parse_apkbuild_checksums(value: &str) -> Vec<(String, String)> {
853 value
854 .lines()
855 .flat_map(|line| line.split_whitespace())
856 .collect::<Vec<_>>()
857 .chunks(2)
858 .filter_map(|chunk| {
859 if chunk.len() == 2 {
860 Some((chunk[1].to_string(), chunk[0].to_string()))
861 } else {
862 None
863 }
864 })
865 .collect()
866}
867
868fn build_alpine_license_data(
869 extracted: Option<&str>,
870) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
871 let Some(extracted) = extracted.map(str::trim).filter(|s| !s.is_empty()) else {
872 return empty_declared_license_data();
873 };
874
875 if extracted == "custom:multiple" {
876 return build_declared_license_data_from_pair(
877 "unknown-license-reference",
878 "LicenseRef-scancode-unknown-license-reference",
879 DeclaredLicenseMatchMetadata::single_line(extracted),
880 );
881 }
882
883 let normalized_tokens = extracted
884 .split_whitespace()
885 .filter(|part| *part != "AND")
886 .map(normalize_alpine_license_token)
887 .collect::<Option<Vec<_>>>();
888
889 let Some(normalized_tokens) = normalized_tokens else {
890 return empty_declared_license_data();
891 };
892
893 let Some(combined) = combine_normalized_licenses(normalized_tokens, " AND ") else {
894 return empty_declared_license_data();
895 };
896
897 build_declared_license_data(
898 combined,
899 DeclaredLicenseMatchMetadata::single_line(extracted),
900 )
901}
902
903fn normalize_alpine_license_token(token: &str) -> Option<NormalizedDeclaredLicense> {
904 match token {
905 "ICU" => Some(NormalizedDeclaredLicense::new("x11", "ICU")),
906 "Unicode-TOU" => Some(NormalizedDeclaredLicense::new("unicode-tou", "Unicode-TOU")),
907 "Ruby" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
908 "BSD-2-Clause" => Some(NormalizedDeclaredLicense::new(
909 "bsd-simplified",
910 "BSD-2-Clause",
911 )),
912 "BSD-3-Clause" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
913 other => normalize_declared_license_key(other),
914 }
915}
916
917fn parse_apkbuild_dependencies(variables: &HashMap<String, String>) -> Vec<Dependency> {
918 let mut dependencies = Vec::new();
919 let mut dep_count = 0;
920
921 for (field, scope, is_runtime, is_optional) in [
922 ("depends", "depends", true, false),
923 ("depends_dev", "depends_dev", false, true),
924 ("makedepends", "makedepends", false, true),
925 ("makedepends_build", "makedepends_build", false, true),
926 ("makedepends_host", "makedepends_host", false, true),
927 ("checkdepends", "checkdepends", false, true),
928 ] {
929 let Some(value) = variables.get(field) else {
930 continue;
931 };
932
933 for dep_str in value.split_whitespace() {
934 let dep_str = dep_str.trim();
935 if dep_str.is_empty() {
936 continue;
937 }
938
939 dep_count += 1;
940 if dep_count > MAX_ITERATION_COUNT {
941 warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_dependencies, truncating");
942 return dependencies;
943 }
944
945 let dep_name = dep_str
946 .split(['<', '>', '=', '!', '~'])
947 .next()
948 .unwrap_or(dep_str)
949 .trim();
950 if dep_name.is_empty() || !is_static_apkbuild_dependency_name(dep_name) {
951 continue;
952 }
953
954 dependencies.push(Dependency {
955 purl: build_alpine_purl(dep_name, None, None),
956 extracted_requirement: Some(dep_str.to_string()),
957 scope: Some(scope.to_string()),
958 is_runtime: Some(is_runtime),
959 is_optional: Some(is_optional),
960 is_pinned: Some(dep_str.contains('=')),
961 is_direct: Some(true),
962 resolved_package: None,
963 extra_data: None,
964 });
965 }
966 }
967
968 dependencies
969}
970
971fn is_static_apkbuild_dependency_name(dep_name: &str) -> bool {
972 let mut chars = dep_name.chars();
973 let Some(first) = chars.next() else {
974 return false;
975 };
976
977 if !first.is_ascii_alphanumeric() {
978 return false;
979 }
980
981 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
982}
983
984fn extract_file_references(raw_text: &str) -> Vec<FileReference> {
985 let mut file_references = Vec::new();
986 let mut current_dir = String::new();
987 let mut current_file: Option<FileReference> = None;
988
989 for line in raw_text.lines().take(MAX_ITERATION_COUNT) {
990 if line.is_empty() {
991 continue;
992 }
993
994 if let Some((field_type, value)) = line.split_once(':') {
995 let value = value.trim();
996 match field_type {
997 "F" => {
998 if let Some(file) = current_file.take() {
999 file_references.push(file);
1000 }
1001 current_dir = value.to_string();
1002 }
1003 "R" => {
1004 if let Some(file) = current_file.take() {
1005 file_references.push(file);
1006 }
1007
1008 let path = if current_dir.is_empty() {
1009 value.to_string()
1010 } else {
1011 format!("{}/{}", current_dir, value)
1012 };
1013
1014 current_file = Some(FileReference {
1015 path,
1016 size: None,
1017 sha1: None,
1018 md5: None,
1019 sha256: None,
1020 sha512: None,
1021 extra_data: None,
1022 });
1023 }
1024 "Z" => {
1025 if let Some(ref mut file) = current_file
1026 && value.starts_with("Q1")
1027 {
1028 use base64::Engine;
1029 if let Ok(decoded) =
1030 base64::engine::general_purpose::STANDARD.decode(&value[2..])
1031 && let Ok(digest) = Sha1Digest::from_hex(
1032 &decoded
1033 .iter()
1034 .map(|b| format!("{:02x}", b))
1035 .collect::<String>(),
1036 )
1037 {
1038 file.sha1 = Some(digest);
1039 }
1040 }
1041 }
1042 "a" => {
1043 if let Some(ref mut file) = current_file {
1044 let mut extra = HashMap::new();
1045 extra.insert(
1046 "attributes".to_string(),
1047 serde_json::Value::String(value.to_string()),
1048 );
1049 file.extra_data = Some(extra);
1050 }
1051 }
1052 _ => {}
1053 }
1054 }
1055 }
1056
1057 if let Some(file) = current_file {
1058 file_references.push(file);
1059 }
1060
1061 file_references
1062}
1063
1064fn extract_providers(raw_text: &str) -> Vec<String> {
1065 let mut providers = Vec::new();
1066
1067 for line in raw_text.lines().take(MAX_ITERATION_COUNT) {
1068 if line.is_empty() {
1069 continue;
1070 }
1071
1072 if let Some(value) = line.strip_prefix("p:") {
1073 providers.extend(value.split_whitespace().map(|s| s.to_string()));
1074 }
1075 }
1076
1077 providers
1078}
1079
1080fn build_alpine_purl(
1081 name: &str,
1082 version: Option<&str>,
1083 architecture: Option<&str>,
1084) -> Option<String> {
1085 use packageurl::PackageUrl;
1086
1087 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
1088
1089 if let Some(ver) = version {
1090 purl.with_version(ver).ok()?;
1091 }
1092
1093 if let Some(arch) = architecture {
1094 purl.add_qualifier("arch", arch).ok()?;
1095 }
1096
1097 Some(purl.to_string())
1098}
1099
1100pub struct AlpineApkParser;
1102
1103impl PackageParser for AlpineApkParser {
1104 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1105
1106 fn is_match(path: &Path) -> bool {
1107 path.extension().and_then(|e| e.to_str()) == Some("apk")
1108 && magic::is_gzip(path)
1109 && !magic::is_zip(path)
1110 && apk_contains_pkginfo(path)
1111 }
1112
1113 fn extract_packages(path: &Path) -> Vec<PackageData> {
1114 vec![match extract_apk_archive(path) {
1115 Ok(data) => data,
1116 Err(e) => {
1117 warn!("Failed to extract .apk archive {:?}: {}", path, e);
1118 PackageData {
1119 package_type: Some(PACKAGE_TYPE),
1120 datasource_id: Some(DatasourceId::AlpineApkArchive),
1121 ..Default::default()
1122 }
1123 }
1124 }]
1125 }
1126}
1127
1128fn apk_contains_pkginfo(path: &Path) -> bool {
1129 let archive_size = match std::fs::metadata(path) {
1130 Ok(m) => m.len(),
1131 Err(_) => return false,
1132 };
1133
1134 if archive_size > MAX_ARCHIVE_SIZE {
1135 warn!(
1136 "Archive {:?} exceeds MAX_ARCHIVE_SIZE ({} bytes)",
1137 path, archive_size
1138 );
1139 return false;
1140 }
1141
1142 apk_pkginfo_content(path, archive_size)
1143 .map(|content| content.is_some())
1144 .unwrap_or(false)
1145}
1146
1147fn extract_apk_archive(path: &Path) -> Result<PackageData, String> {
1148 let archive_size = std::fs::metadata(path)
1149 .map_err(|e| format!("Failed to stat .apk file: {}", e))?
1150 .len();
1151
1152 if archive_size > MAX_ARCHIVE_SIZE {
1153 return Err(format!(
1154 "Archive {:?} is {} bytes, exceeding MAX_ARCHIVE_SIZE ({} bytes)",
1155 path, archive_size, MAX_ARCHIVE_SIZE
1156 ));
1157 }
1158
1159 let content = apk_pkginfo_content(path, archive_size)?
1160 .ok_or_else(|| ".apk archive does not contain .PKGINFO file".to_string())?;
1161
1162 Ok(parse_pkginfo(&content))
1163}
1164
1165fn apk_pkginfo_content(path: &Path, archive_size: u64) -> Result<Option<String>, String> {
1166 use flate2::read::MultiGzDecoder;
1167 use std::io::Read;
1168
1169 let file = std::fs::File::open(path).map_err(|e| format!("Failed to open .apk file: {}", e))?;
1170 let mut decoder = MultiGzDecoder::new(file);
1171 let mut decompressed = Vec::new();
1172 decoder
1173 .read_to_end(&mut decompressed)
1174 .map_err(|e| format!("Failed to decompress .apk archive: {}", e))?;
1175
1176 if decompressed.len() as u64 > MAX_ARCHIVE_SIZE {
1177 return Err(format!("Total extracted size exceeds limit for {:?}", path));
1178 }
1179
1180 let mut offset = 0usize;
1181 while offset + 512 <= decompressed.len() {
1182 let header = &decompressed[offset..offset + 512];
1183 if header.iter().all(|b| *b == 0) {
1184 offset += 512;
1185 continue;
1186 }
1187
1188 let name_end = header[..100].iter().position(|b| *b == 0).unwrap_or(100);
1189 let entry_name = String::from_utf8_lossy(&header[..name_end]);
1190 if entry_name.contains("..") {
1191 warn!("Skipping tar entry with path traversal: {}", entry_name);
1192 offset += 512;
1193 continue;
1194 }
1195
1196 let size_field = &header[124..136];
1197 let size_text = String::from_utf8_lossy(size_field).into_owned();
1198 let size_text = size_text.trim_matches(char::from(0)).trim();
1199 let size = usize::from_str_radix(size_text, 8)
1200 .map_err(|e| format!("Failed to parse tar entry size for {:?}: {}", path, e))?;
1201
1202 if size as u64 > MAX_FILE_SIZE {
1203 warn!(
1204 "Entry {:?} in {:?} exceeds MAX_FILE_SIZE ({} bytes)",
1205 entry_name, path, size
1206 );
1207 offset += 512 + size.div_ceil(512) * 512;
1208 continue;
1209 }
1210
1211 if archive_size > 0 {
1212 let ratio = size as f64 / archive_size as f64;
1213 if ratio > MAX_COMPRESSION_RATIO {
1214 warn!("Suspicious compression ratio in {:?}: {:.2}:1", path, ratio);
1215 offset += 512 + size.div_ceil(512) * 512;
1216 continue;
1217 }
1218 }
1219
1220 let data_start = offset + 512;
1221 let data_end = data_start + size;
1222 if data_end > decompressed.len() {
1223 return Err(format!(
1224 "Tar entry {:?} exceeds decompressed archive size",
1225 entry_name
1226 ));
1227 }
1228
1229 if entry_name.ends_with(".PKGINFO") {
1230 let content = String::from_utf8(decompressed[data_start..data_end].to_vec())
1231 .map_err(|e| format!("Failed to decode .PKGINFO as UTF-8: {}", e))?;
1232 return Ok(Some(content));
1233 }
1234
1235 offset = data_start + size.div_ceil(512) * 512;
1236 }
1237
1238 Ok(None)
1239}
1240
1241fn parse_pkginfo(content: &str) -> PackageData {
1242 let mut fields: HashMap<&str, Vec<&str>> = HashMap::new();
1243
1244 for line in content.lines().take(MAX_ITERATION_COUNT) {
1245 let line = line.trim();
1246 if line.is_empty() || line.starts_with('#') {
1247 continue;
1248 }
1249
1250 if let Some((key, value)) = line.split_once(" = ") {
1251 fields.entry(key.trim()).or_default().push(value.trim());
1252 }
1253 }
1254
1255 let name = fields
1256 .get("pkgname")
1257 .and_then(|v| v.first())
1258 .map(|s| truncate_field(s.to_string()));
1259 let pkgver = fields.get("pkgver").and_then(|v| v.first());
1260 let version = pkgver.map(|s| truncate_field(s.to_string()));
1261 let arch = fields
1262 .get("arch")
1263 .and_then(|v| v.first())
1264 .map(|s| truncate_field(s.to_string()));
1265 let license = fields
1266 .get("license")
1267 .and_then(|v| v.first())
1268 .map(|s| truncate_field(s.to_string()));
1269 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
1270 build_alpine_license_data(license.as_deref());
1271 let description = fields
1272 .get("pkgdesc")
1273 .and_then(|v| v.first())
1274 .map(|s| truncate_field(s.to_string()));
1275 let homepage = fields
1276 .get("url")
1277 .and_then(|v| v.first())
1278 .map(|s| truncate_field(s.to_string()));
1279 let origin = fields
1280 .get("origin")
1281 .and_then(|v| v.first())
1282 .map(|s| truncate_field(s.to_string()));
1283 let maintainer_str = fields.get("maintainer").and_then(|v| v.first());
1284
1285 let mut parties = Vec::new();
1286 if let Some(maint) = maintainer_str {
1287 let (maint_name, maint_email) = split_name_email(maint);
1288 parties.push(Party {
1289 r#type: Some("person".to_string()),
1290 role: Some("maintainer".to_string()),
1291 name: maint_name,
1292 email: maint_email,
1293 url: None,
1294 organization: None,
1295 organization_url: None,
1296 timezone: None,
1297 });
1298 }
1299
1300 let purl = name
1301 .as_ref()
1302 .and_then(|n| build_alpine_purl(n, version.as_deref(), arch.as_deref()));
1303
1304 let mut dependencies = Vec::new();
1305 if let Some(depends_list) = fields.get("depend") {
1306 for (i, dep_str) in depends_list.iter().enumerate() {
1307 if i >= MAX_ITERATION_COUNT {
1308 warn!("Exceeded MAX_ITERATION_COUNT in parse_pkginfo dependencies, truncating");
1309 break;
1310 }
1311 let dep_name = dep_str.split_whitespace().next().unwrap_or(dep_str);
1312 dependencies.push(Dependency {
1313 purl: Some(format!("pkg:alpine/{}", dep_name)),
1314 extracted_requirement: Some(dep_str.to_string()),
1315 scope: Some("runtime".to_string()),
1316 is_runtime: Some(true),
1317 is_optional: Some(false),
1318 is_pinned: None,
1319 is_direct: Some(true),
1320 resolved_package: None,
1321 extra_data: None,
1322 });
1323 }
1324 }
1325
1326 PackageData {
1327 datasource_id: Some(DatasourceId::AlpineApkArchive),
1328 package_type: Some(PACKAGE_TYPE),
1329 namespace: Some("alpine".to_string()),
1330 name,
1331 version,
1332 description,
1333 homepage_url: homepage,
1334 declared_license_expression,
1335 declared_license_expression_spdx,
1336 license_detections,
1337 extracted_license_statement: license,
1338 parties,
1339 dependencies,
1340 purl,
1341 extra_data: origin.map(|o| {
1342 let mut map = HashMap::new();
1343 map.insert("origin".to_string(), serde_json::Value::String(o));
1344 map
1345 }),
1346 ..Default::default()
1347 }
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353 use std::io::Write;
1354 use std::path::PathBuf;
1355 use tempfile::TempDir;
1356
1357 fn create_temp_installed_db(content: &str) -> (TempDir, PathBuf) {
1360 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1361 let db_dir = temp_dir.path().join("lib/apk/db");
1362 std::fs::create_dir_all(&db_dir).expect("Failed to create db dir");
1363 let file_path = db_dir.join("installed");
1364 let mut file = std::fs::File::create(&file_path).expect("Failed to create file");
1365 file.write_all(content.as_bytes())
1366 .expect("Failed to write content");
1367 (temp_dir, file_path)
1368 }
1369
1370 #[test]
1371 fn test_alpine_parser_is_match() {
1372 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1373 "/lib/apk/db/installed"
1374 )));
1375 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1376 "/var/lib/apk/db/installed"
1377 )));
1378 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1379 "/lib/apk/db/status"
1380 )));
1381 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1382 "installed"
1383 )));
1384 }
1385
1386 #[test]
1387 fn test_parse_alpine_package_basic() {
1388 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1389P:alpine-baselayout-data
1390V:3.2.0-r22
1391A:x86_64
1392S:11435
1393I:73728
1394T:Alpine base dir structure and init scripts
1395U:https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout
1396L:GPL-2.0-only
1397o:alpine-baselayout
1398m:Natanael Copa <ncopa@alpinelinux.org>
1399t:1655134784
1400c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1401
1402";
1403 let (_dir, path) = create_temp_installed_db(content);
1404 let pkg = AlpineInstalledParser::extract_first_package(&path);
1405 assert_eq!(pkg.name, Some("alpine-baselayout-data".to_string()));
1406 assert_eq!(pkg.version, Some("3.2.0-r22".to_string()));
1407 assert_eq!(pkg.namespace, Some("alpine".to_string()));
1408 assert_eq!(
1409 pkg.description,
1410 Some("Alpine base dir structure and init scripts".to_string())
1411 );
1412 assert_eq!(
1413 pkg.homepage_url,
1414 Some("https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout".to_string())
1415 );
1416 assert_eq!(
1417 pkg.extracted_license_statement,
1418 Some("GPL-2.0-only".to_string())
1419 );
1420 assert_eq!(pkg.parties.len(), 1);
1421 assert_eq!(pkg.parties[0].name, Some("Natanael Copa".to_string()));
1422 assert_eq!(
1423 pkg.parties[0].email,
1424 Some("ncopa@alpinelinux.org".to_string())
1425 );
1426 assert!(
1427 pkg.purl
1428 .as_ref()
1429 .unwrap()
1430 .contains("alpine-baselayout-data")
1431 );
1432 assert!(pkg.purl.as_ref().unwrap().contains("arch=x86_64"));
1433 }
1434
1435 #[test]
1436 fn test_parse_alpine_with_dependencies() {
1437 let content = "P:musl
1438V:1.2.3-r0
1439A:x86_64
1440D:scanelf so:libc.musl-x86_64.so.1
1441
1442";
1443 let (_dir, path) = create_temp_installed_db(content);
1444 let pkg = AlpineInstalledParser::extract_first_package(&path);
1445 assert_eq!(pkg.name, Some("musl".to_string()));
1446 assert_eq!(pkg.dependencies.len(), 1);
1447 assert!(
1448 pkg.dependencies[0]
1449 .purl
1450 .as_ref()
1451 .unwrap()
1452 .contains("scanelf")
1453 );
1454 }
1455
1456 #[test]
1457 fn test_build_alpine_purl() {
1458 let purl = build_alpine_purl("busybox", Some("1.31.1-r9"), Some("x86_64"));
1459 assert_eq!(
1460 purl,
1461 Some("pkg:alpine/busybox@1.31.1-r9?arch=x86_64".to_string())
1462 );
1463
1464 let purl_no_arch = build_alpine_purl("package", Some("1.0"), None);
1465 assert_eq!(purl_no_arch, Some("pkg:alpine/package@1.0".to_string()));
1466 }
1467
1468 #[test]
1469 fn test_parse_alpine_extra_data() {
1470 let content = "P:test-package
1471V:1.0
1472C:base64checksum==
1473S:12345
1474I:67890
1475t:1234567890
1476c:gitcommithash
1477
1478";
1479 let (_dir, path) = create_temp_installed_db(content);
1480 let pkg = AlpineInstalledParser::extract_first_package(&path);
1481 assert!(pkg.extra_data.is_some());
1482 let extra = pkg.extra_data.as_ref().unwrap();
1483 assert_eq!(extra["checksum"], "base64checksum==");
1484 assert_eq!(extra["compressed_size"], "12345");
1485 assert_eq!(extra["installed_size"], "67890");
1486 assert_eq!(extra["build_timestamp"], "1234567890");
1487 assert_eq!(extra["git_commit"], "gitcommithash");
1488 }
1489
1490 #[test]
1491 fn test_parse_alpine_case_sensitive_keys() {
1492 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1493P:test-pkg
1494V:1.0
1495T:A test description
1496t:1655134784
1497c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1498
1499";
1500 let (_dir, path) = create_temp_installed_db(content);
1501 let pkg = AlpineInstalledParser::extract_first_package(&path);
1502 assert_eq!(pkg.description, Some("A test description".to_string()));
1503 let extra = pkg.extra_data.as_ref().unwrap();
1504 assert_eq!(extra["checksum"], "Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=");
1505 assert_eq!(extra["build_timestamp"], "1655134784");
1506 assert_eq!(
1507 extra["git_commit"],
1508 "cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1509 );
1510 }
1511
1512 #[test]
1513 fn test_parse_alpine_multiple_packages() {
1514 let content = "P:package1
1515V:1.0
1516A:x86_64
1517
1518P:package2
1519V:2.0
1520A:aarch64
1521
1522";
1523 let (_dir, path) = create_temp_installed_db(content);
1524 let pkgs = AlpineInstalledParser::extract_packages(&path);
1525 assert_eq!(pkgs.len(), 2);
1526 assert_eq!(pkgs[0].name, Some("package1".to_string()));
1527 assert_eq!(pkgs[0].version, Some("1.0".to_string()));
1528 assert_eq!(pkgs[1].name, Some("package2".to_string()));
1529 assert_eq!(pkgs[1].version, Some("2.0".to_string()));
1530 }
1531
1532 #[test]
1533 fn test_parse_alpine_file_references() {
1534 let content = "P:test-pkg
1535V:1.0
1536F:usr/bin
1537R:test
1538Z:Q1WTc55xfvPogzA0YUV24D0Ym+MKE=
1539F:etc
1540R:config
1541Z:Q1pcfTfDNEbNKQc2s1tia7da05M8Q=
1542
1543";
1544 let (_dir, path) = create_temp_installed_db(content);
1545 let pkg = AlpineInstalledParser::extract_first_package(&path);
1546 assert_eq!(pkg.file_references.len(), 2);
1547 assert_eq!(pkg.file_references[0].path, "usr/bin/test");
1548 assert!(pkg.file_references[0].sha1.is_some());
1549 assert_eq!(pkg.file_references[1].path, "etc/config");
1550 assert!(pkg.file_references[1].sha1.is_some());
1551 }
1552
1553 #[test]
1554 fn test_parse_alpine_empty_fields() {
1555 let content = "P:minimal-package
1556V:1.0
1557
1558";
1559 let (_dir, path) = create_temp_installed_db(content);
1560 let pkg = AlpineInstalledParser::extract_first_package(&path);
1561 assert_eq!(pkg.name, Some("minimal-package".to_string()));
1562 assert_eq!(pkg.version, Some("1.0".to_string()));
1563 assert!(pkg.description.is_none());
1564 assert!(pkg.homepage_url.is_none());
1565 assert_eq!(pkg.dependencies.len(), 0);
1566 }
1567
1568 #[test]
1569 fn test_parse_alpine_origin_field() {
1570 let content = "P:busybox-ifupdown
1571V:1.35.0-r13
1572o:busybox
1573A:x86_64
1574
1575";
1576 let (_dir, path) = create_temp_installed_db(content);
1577 let pkg = AlpineInstalledParser::extract_first_package(&path);
1578 assert_eq!(pkg.name, Some("busybox-ifupdown".to_string()));
1579 assert_eq!(pkg.source_packages.len(), 1);
1580 assert_eq!(pkg.source_packages[0], "pkg:alpine/busybox");
1581 }
1582
1583 #[test]
1584 fn test_parse_alpine_url_field() {
1585 let content = "P:openssl
1586V:1.1.1q-r0
1587U:https://www.openssl.org
1588A:x86_64
1589
1590";
1591 let (_dir, path) = create_temp_installed_db(content);
1592 let pkg = AlpineInstalledParser::extract_first_package(&path);
1593 assert_eq!(
1594 pkg.homepage_url,
1595 Some("https://www.openssl.org".to_string())
1596 );
1597 }
1598
1599 #[test]
1600 fn test_parse_alpine_provider_field() {
1601 let content = "P:some-package
1602V:1.0
1603p:cmd:binary=1.0
1604p:so:libtest.so.1
1605
1606";
1607 let (_dir, path) = create_temp_installed_db(content);
1608 let pkg = AlpineInstalledParser::extract_first_package(&path);
1609 assert!(pkg.extra_data.is_some());
1610 let extra = pkg.extra_data.as_ref().unwrap();
1611 let providers = extra.get("providers").and_then(|v| v.as_array());
1612 assert!(providers.is_some());
1613 let provider_array = providers.unwrap();
1614 assert_eq!(provider_array.len(), 2);
1615 assert_eq!(provider_array[0].as_str(), Some("cmd:binary=1.0"));
1616 assert_eq!(provider_array[1].as_str(), Some("so:libtest.so.1"));
1617 }
1618
1619 #[test]
1620 fn test_alpine_apk_parser_is_match() {
1621 let apk_path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1622
1623 assert!(AlpineApkParser::is_match(&apk_path));
1624 assert!(!AlpineApkParser::is_match(&PathBuf::from("package.tar.gz")));
1625 assert!(!AlpineApkParser::is_match(&PathBuf::from("installed")));
1626 }
1627
1628 #[test]
1629 fn test_alpine_apk_parser_rejects_android_and_placeholder_apk_fixtures() {
1630 let android_apk = PathBuf::from("testdata/misc/test_android.apk");
1631 let placeholder_alpine_apk = PathBuf::from("testdata/misc/test_alpine.apk");
1632 let valid_alpine_apk = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1633
1634 assert!(!AlpineApkParser::is_match(&android_apk));
1635 assert!(!AlpineApkParser::is_match(&placeholder_alpine_apk));
1636 assert!(AlpineApkParser::is_match(&valid_alpine_apk));
1637 }
1638
1639 #[test]
1640 fn test_alpine_apk_parser_supports_concatenated_gzip_members() {
1641 use flate2::Compression;
1642 use flate2::write::GzEncoder;
1643 use std::io::Write;
1644 use tar::{Builder, Header};
1645
1646 fn gzip_tar_member(path: &str, contents: &[u8]) -> Vec<u8> {
1647 let encoder = GzEncoder::new(Vec::new(), Compression::default());
1648 let mut builder = Builder::new(encoder);
1649 let mut header = Header::new_gnu();
1650 header.set_size(contents.len() as u64);
1651 header.set_mode(0o644);
1652 header.set_cksum();
1653 builder
1654 .append_data(&mut header, path, contents)
1655 .expect("append tar entry");
1656 let encoder = builder.into_inner().expect("finish tar builder");
1657 encoder.finish().expect("finish gzip encoder")
1658 }
1659
1660 let temp_dir = tempfile::TempDir::new().expect("create temp dir");
1661 let apk_path = temp_dir.path().join("synthetic.apk");
1662
1663 let signature_member = gzip_tar_member(
1664 ".SIGN.RSA.alpine-devel@lists.alpinelinux.org-test.rsa.pub",
1665 b"signature",
1666 );
1667 let pkginfo_member = gzip_tar_member(
1668 ".PKGINFO",
1669 b"pkgname = synthetic\npkgver = 1.0-r0\npkgdesc = Synthetic APK\nurl = https://example.com\nlicense = MIT\narch = x86_64\n",
1670 );
1671
1672 let mut file = std::fs::File::create(&apk_path).expect("create synthetic apk");
1673 file.write_all(&signature_member)
1674 .expect("write signature member");
1675 file.write_all(&pkginfo_member)
1676 .expect("write pkginfo member");
1677
1678 assert!(AlpineApkParser::is_match(&apk_path));
1679 let pkg = AlpineApkParser::extract_first_package(&apk_path);
1680 assert_eq!(pkg.name.as_deref(), Some("synthetic"));
1681 assert_eq!(pkg.version.as_deref(), Some("1.0-r0"));
1682 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1683 }
1684
1685 #[test]
1686 fn test_alpine_apkbuild_parser_is_match() {
1687 assert!(AlpineApkbuildParser::is_match(&PathBuf::from("APKBUILD")));
1688 assert!(AlpineApkbuildParser::is_match(&PathBuf::from(
1689 "/path/to/APKBUILD"
1690 )));
1691 assert!(AlpineApkbuildParser::is_match(&PathBuf::from(
1692 "linux-firmware-APKBUILD"
1693 )));
1694 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from("apkbuild")));
1695 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from(
1696 "APKBUILD.txt"
1697 )));
1698 }
1699
1700 #[test]
1701 fn test_parse_apkbuild_icu_reference() {
1702 let path = PathBuf::from("testdata/alpine-fixtures/apkbuild/alpine14/main/icu/APKBUILD");
1703 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1704
1705 assert_eq!(pkg.datasource_id, Some(DatasourceId::AlpineApkbuild));
1706 assert_eq!(pkg.name.as_deref(), Some("icu"));
1707 assert_eq!(pkg.version.as_deref(), Some("67.1-r2"));
1708 assert_eq!(
1709 pkg.description.as_deref(),
1710 Some("International Components for Unicode library")
1711 );
1712 assert_eq!(
1713 pkg.homepage_url.as_deref(),
1714 Some("http://site.icu-project.org/")
1715 );
1716 assert_eq!(
1717 pkg.extracted_license_statement.as_deref(),
1718 Some("MIT ICU Unicode-TOU")
1719 );
1720 assert_eq!(
1721 pkg.declared_license_expression_spdx.as_deref(),
1722 Some("ICU AND MIT AND Unicode-TOU")
1723 );
1724 assert_eq!(pkg.dependencies.len(), 3);
1725 let depends_dev = pkg
1726 .dependencies
1727 .iter()
1728 .find(|dep| dep.scope.as_deref() == Some("depends_dev"))
1729 .expect("depends_dev dependency missing");
1730 assert_eq!(depends_dev.purl.as_deref(), Some("pkg:alpine/icu"));
1731 assert_eq!(depends_dev.is_runtime, Some(false));
1732 assert_eq!(depends_dev.is_optional, Some(true));
1733
1734 let check_dep_names: Vec<_> = pkg
1735 .dependencies
1736 .iter()
1737 .filter(|dep| dep.scope.as_deref() == Some("checkdepends"))
1738 .filter_map(|dep| dep.purl.as_deref())
1739 .collect();
1740 assert!(check_dep_names.contains(&"pkg:alpine/diffutils"));
1741 assert!(check_dep_names.contains(&"pkg:alpine/python3"));
1742 let extra = pkg.extra_data.as_ref().unwrap();
1743 assert!(extra.contains_key("sources"));
1744 assert!(extra.contains_key("checksums"));
1745 }
1746
1747 #[test]
1748 fn test_parse_apkbuild_custom_multiple_license_uses_raw_matched_text() {
1749 let path = PathBuf::from(
1750 "testdata/alpine-fixtures/apkbuild/alpine13/main/linux-firmware/APKBUILD",
1751 );
1752 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1753
1754 assert_eq!(pkg.name.as_deref(), Some("linux-firmware"));
1755 assert_eq!(pkg.version.as_deref(), Some("20201218-r0"));
1756 assert_eq!(
1757 pkg.extracted_license_statement.as_deref(),
1758 Some("custom:multiple")
1759 );
1760 assert_eq!(
1761 pkg.declared_license_expression.as_deref(),
1762 Some("unknown-license-reference")
1763 );
1764 assert_eq!(
1765 pkg.declared_license_expression_spdx.as_deref(),
1766 Some("LicenseRef-scancode-unknown-license-reference")
1767 );
1768 let matched = pkg.license_detections[0].matches[0].matched_text.as_deref();
1769 assert_eq!(matched, Some("custom:multiple"));
1770 }
1771
1772 #[test]
1773 fn test_parse_apkbuild_self_referential_makedepends_uses_previous_values() {
1774 let content = r#"
1775pkgname=util-linux
1776pkgver=2.41.4
1777pkgrel=0
1778makedepends_build="bash"
1779makedepends_host="
1780 libcap-ng-dev
1781 linux-headers
1782 "
1783if [ -z "$BOOTSTRAP" ]; then
1784 makedepends_build="$makedepends_build asciidoctor"
1785 makedepends_host="$makedepends_host python3-dev"
1786fi
1787makedepends="$makedepends_build $makedepends_host"
1788"#;
1789
1790 let variables = parse_apkbuild_variables(content);
1791
1792 assert_eq!(
1793 variables.get("makedepends_build").map(String::as_str),
1794 Some("bash asciidoctor")
1795 );
1796 let makedepends_host = variables
1797 .get("makedepends_host")
1798 .expect("makedepends_host should resolve");
1799 assert!(makedepends_host.contains("libcap-ng-dev"));
1800 assert!(makedepends_host.contains("linux-headers"));
1801 assert!(makedepends_host.contains("python3-dev"));
1802 assert!(!makedepends_host.contains("$makedepends_host"));
1803
1804 let makedepends = variables
1805 .get("makedepends")
1806 .expect("makedepends should resolve");
1807 assert!(makedepends.contains("bash asciidoctor"));
1808 assert!(makedepends.contains("libcap-ng-dev"));
1809 assert!(makedepends.contains("linux-headers"));
1810 assert!(makedepends.contains("python3-dev"));
1811 assert!(!makedepends.contains("$makedepends_build"));
1812 assert!(!makedepends.contains("$makedepends_host"));
1813 }
1814
1815 #[test]
1816 fn test_parse_apkbuild_skips_unresolved_shell_fragments_in_dependencies() {
1817 let content = r#"
1818pkgname=test
1819pkgver=1.0
1820pkgrel=0
1821makedepends="$makedepends_build ${_target/./_} openjdk$_jdkbuild-jdk bash %22 aarch64)"
1822"#;
1823
1824 let pkg = parse_apkbuild(content);
1825 let dependency_purls: Vec<_> = pkg
1826 .dependencies
1827 .iter()
1828 .filter_map(|dep| dep.purl.as_deref())
1829 .collect();
1830
1831 assert_eq!(dependency_purls, vec!["pkg:alpine/bash"]);
1832 }
1833
1834 #[test]
1835 fn test_parse_apkbuild_ignores_inline_comments_after_dependency_values() {
1836 let content = r#"
1837pkgname=bat
1838pkgver=0.26.1
1839pkgrel=0
1840depends="less" # Required for RAW-CONTROL-CHARS
1841makedepends="e2fsprogs-dev" # is pulled in externally.
1842checkdepends="bash"
1843"#;
1844
1845 let pkg = parse_apkbuild(content);
1846 let dependency_purls: Vec<_> = pkg
1847 .dependencies
1848 .iter()
1849 .filter_map(|dep| dep.purl.as_deref())
1850 .collect();
1851
1852 assert_eq!(
1853 dependency_purls,
1854 vec![
1855 "pkg:alpine/less",
1856 "pkg:alpine/e2fsprogs-dev",
1857 "pkg:alpine/bash",
1858 ]
1859 );
1860 }
1861
1862 #[test]
1863 fn test_resolve_apkbuild_value_supports_common_parameter_expansions() {
1864 let variables = HashMap::from([
1865 ("_pkgver".to_string(), "1.6.0-641".to_string()),
1866 ("_iverilog".to_string(), "13_0".to_string()),
1867 ("pkgver".to_string(), "18.2.7".to_string()),
1868 ("_krel".to_string(), "0".to_string()),
1869 ("_rel".to_string(), "2".to_string()),
1870 ("FLAVOR".to_string(), "".to_string()),
1871 ]);
1872
1873 assert_eq!(
1874 resolve_apkbuild_value("${_pkgver/-/.}", &variables),
1875 "1.6.0.641"
1876 );
1877 assert_eq!(resolve_apkbuild_value("${pkgver%%.*}", &variables), "18");
1878 assert_eq!(resolve_apkbuild_value("${pkgver%.*}", &variables), "18.2");
1879 assert_eq!(resolve_apkbuild_value("${_iverilog##*_}", &variables), "0");
1880 assert_eq!(
1881 resolve_apkbuild_value("${_iverilog%%_*}.${_iverilog##*_}", &variables),
1882 "13.0"
1883 );
1884 assert_eq!(
1885 resolve_apkbuild_value("$(( _krel + _rel ))", &variables),
1886 "2"
1887 );
1888 assert_eq!(resolve_apkbuild_value("${FLAVOR:-lts}", &variables), "lts");
1889 }
1890
1891 #[test]
1892 fn test_parse_apkbuild_keeps_initial_package_identity_assignment() {
1893 let content = r#"
1894pkgname=go
1895pkgver=1.26.2
1896pkgrel=0
1897if [ "$CBUILD" != "$CHOST" ]; then
1898 pkgname="go-bootstrap"
1899 pkgrel=1
1900fi
1901"#;
1902
1903 let variables = parse_apkbuild_variables(content);
1904 assert_eq!(variables.get("pkgname").map(String::as_str), Some("go"));
1905 }
1906
1907 #[test]
1908 fn test_parse_apkbuild_strips_concatenated_shell_quotes_from_package_name() {
1909 let content = r#"
1910_pkgname=cinny
1911pkgname="$_pkgname"-web
1912pkgver=4.11.1
1913pkgrel=0
1914"#;
1915
1916 let pkg = parse_apkbuild(content);
1917 assert_eq!(pkg.name.as_deref(), Some("cinny-web"));
1918 }
1919
1920 #[test]
1921 fn test_parse_apkbuild_re_resolves_forward_references_in_package_identity() {
1922 let content = r#"
1923pkgname=ceph${pkgver%%.*}
1924pkgver=18.2.7
1925pkgrel=7
1926"#;
1927
1928 let pkg = parse_apkbuild(content);
1929 assert_eq!(pkg.name.as_deref(), Some("ceph18"));
1930 assert_eq!(pkg.version.as_deref(), Some("18.2.7-r7"));
1931 }
1932
1933 #[test]
1934 fn test_parse_apkbuild_supports_empty_global_replacement_in_pkgver() {
1935 let content = r#"
1936pkgname=quickjs
1937_pkgver=2025-09-13
1938pkgver=0.${_pkgver//-}
1939pkgrel=0
1940"#;
1941
1942 let pkg = parse_apkbuild(content);
1943 assert_eq!(pkg.version.as_deref(), Some("0.20250913-r0"));
1944 }
1945
1946 #[test]
1947 fn test_parse_apkbuild_supports_split_version_parts() {
1948 let content = r#"
1949pkgname=iverilog
1950_pkgver=13_0
1951pkgver=${_pkgver%%_*}.${_pkgver##*_}
1952pkgrel=0
1953"#;
1954
1955 let variables = parse_apkbuild_variables(content);
1956 assert_eq!(variables.get("pkgver").map(String::as_str), Some("13.0"));
1957
1958 let pkg = parse_apkbuild(content);
1959 assert_eq!(pkg.version.as_deref(), Some("13.0-r0"));
1960 }
1961
1962 #[test]
1963 fn test_parse_apkbuild_keeps_loop_assignments_from_blowing_up_dependencies() {
1964 let content = r#"
1965pkgname=alpine-ipxe
1966pkgver=1.20.1
1967pkgrel=2
1968makedepends="xz-dev perl coreutils bash"
1969_targets="bin/ipxe.iso bin/ipxe.lkrn"
1970for _target in $_targets; do
1971 _target=${_target##*/}
1972 _target=${_target/./_}
1973 subpackages="$subpackages $pkgname-$_target:_split"
1974done
1975"#;
1976
1977 let pkg = parse_apkbuild(content);
1978 let dependency_purls: Vec<_> = pkg
1979 .dependencies
1980 .iter()
1981 .filter_map(|dep| dep.purl.as_deref())
1982 .collect();
1983
1984 assert_eq!(
1985 dependency_purls,
1986 vec![
1987 "pkg:alpine/xz-dev",
1988 "pkg:alpine/perl",
1989 "pkg:alpine/coreutils",
1990 "pkg:alpine/bash",
1991 ]
1992 );
1993 }
1994
1995 #[test]
1996 fn test_parse_alpine_no_files_package_still_detected() {
1997 let path = PathBuf::from("testdata/alpine-fixtures/full-installed/installed");
1998 let content = std::fs::read_to_string(&path).expect("read installed db fixture");
1999 let packages = parse_alpine_installed_db(&content);
2000 let libc_utils = packages
2001 .into_iter()
2002 .find(|pkg| pkg.name.as_deref() == Some("libc-utils"))
2003 .expect("libc-utils package should exist");
2004
2005 assert_eq!(libc_utils.file_references.len(), 0);
2006 assert!(
2007 libc_utils
2008 .purl
2009 .as_deref()
2010 .is_some_and(|p| p.contains("libc-utils"))
2011 );
2012 }
2013
2014 #[test]
2015 fn test_parse_alpine_commit_generates_https_vcs_url() {
2016 let content =
2017 "P:test-package\nV:1.0-r0\nA:x86_64\nc:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd\n";
2018 let (_dir, path) = create_temp_installed_db(content);
2019 let pkg = AlpineInstalledParser::extract_first_package(&path);
2020
2021 assert_eq!(
2022 pkg.vcs_url.as_deref(),
2023 Some(
2024 "git+https://git.alpinelinux.org/aports/commit/?id=cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
2025 )
2026 );
2027 }
2028
2029 #[test]
2030 fn test_parse_alpine_virtual_package() {
2031 let content = "P:.postgis-rundeps
2032V:20210104.190748
2033A:noarch
2034S:0
2035I:0
2036T:virtual meta package
2037U:
2038L:
2039D:json-c geos gdal proj protobuf-c libstdc++
2040
2041";
2042 let (_dir, path) = create_temp_installed_db(content);
2043 let pkg = AlpineInstalledParser::extract_first_package(&path);
2044 assert_eq!(pkg.name, Some(".postgis-rundeps".to_string()));
2045 assert_eq!(pkg.version, Some("20210104.190748".to_string()));
2046 assert_eq!(pkg.description, Some("virtual meta package".to_string()));
2047 assert!(pkg.extra_data.is_some());
2048 let extra = pkg.extra_data.as_ref().unwrap();
2049 assert_eq!(
2050 extra.get("is_virtual").and_then(|v| v.as_bool()),
2051 Some(true)
2052 );
2053 assert_eq!(pkg.dependencies.len(), 6);
2054 assert!(pkg.homepage_url.is_none());
2055 assert!(pkg.extracted_license_statement.is_none());
2056 }
2057
2058 #[test]
2059 fn test_installed_db_license_normalization() {
2060 let content = "P:test-package\nV:1.0-r0\nA:x86_64\nL:MIT\n\n";
2061 let (_dir, path) = create_temp_installed_db(content);
2062 let pkg = AlpineInstalledParser::extract_first_package(&path);
2063
2064 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
2065 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
2066 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
2067 assert_eq!(pkg.license_detections.len(), 1);
2068 }
2069
2070 #[test]
2071 fn test_apk_archive_license_normalization() {
2072 let path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
2073 let pkg = AlpineApkParser::extract_first_package(&path);
2074
2075 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
2076 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
2077 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
2078 assert_eq!(pkg.license_detections.len(), 1);
2079 }
2080}
2081
2082crate::register_parser!(
2083 "Alpine Linux package (installed db and .apk archive)",
2084 &["**/lib/apk/db/installed", "**/*.apk"],
2085 "alpine",
2086 "",
2087 Some("https://github.com/alpinelinux/apk-tools/blob/master/doc/apk-v2.5.scd"),
2088);
2089
2090crate::register_parser!(
2091 "Alpine Linux APKBUILD recipe",
2092 &["**/APKBUILD"],
2093 "alpine",
2094 "Shell",
2095 Some("https://github.com/alpinelinux/abuild/blob/master/APKBUILD.5.scd"),
2096);