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