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