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