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