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