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